When a Layer Is Present but the Boundary Is Not
Practice · 2026-10-31
A practical look at why layered structure alone does not create meaningful boundaries, and how translation, ownership, naming, and responsibility make layers protect the system instead of merely organizing it.
When a Layer Is Present but the Boundary Is Not
1. Introduction: Layers Do Not Automatically Create Boundaries
Layered architecture is often introduced with good intentions.
The application is divided into projects, folders, namespaces, or assemblies. Controllers are placed in one area. Application services in another. Domain objects may have their own project. Persistence code is moved into infrastructure. UI code stays near the presentation layer. Tests are organized around the same structure.
At first glance, this can look like a clean architecture.
The code is no longer all in one place. Responsibilities appear to have been separated. Dependencies may even point mostly in the right direction. The solution structure suggests that the system has clear boundaries.
But layers and boundaries are not the same thing.
A layer is a structural location.
A boundary is a design decision.
A layer says where code lives.
A boundary says what is allowed to pass, what must be translated, what each side is responsible for, and which concepts belong to which part of the system.
That difference matters because it is possible to have layers without meaningful boundaries.
An application may have a presentation layer, application layer, domain layer, and infrastructure layer, but still allow framework types, persistence models, UI assumptions, database concerns, and transport details to move through them almost unchanged.
The structure looks layered.
The responsibilities are still blurred.
Layers create structure, but boundaries are created by translation, ownership, and protected responsibility.
For example, a controller may call an application service, but pass a web input model directly into it. The application service may call a repository, but return a persistence entity directly to the UI. The domain object may exist, but most decisions may still be made in page handlers, query filters, or mapping code. The infrastructure project may be separate, but its concepts may still shape application language.
In that situation, the layer is present.
The boundary is not.
This can be difficult to notice because the code may look organized. The folders are named well. The projects are separated. The architecture diagram may show arrows in the expected direction.
But the real test is not whether the code is placed in different locations.
The real test is whether each part protects its responsibility.
A useful boundary forces translation.
- A web request becomes an application request.
- A persistence result becomes an application result or a read model.
- A technical failure becomes an application outcome.
- A UI decision becomes a presentation concern rather than a workflow rule.
- A database query becomes a named application question rather than something every caller assembles directly.
Without that translation, layers can become pass-through structures.
They add distance without adding protection.
The code may move through more classes, but the same assumptions still travel from one end of the system to the other.
That creates a false sense of separation.
Developers may believe the system is well layered because the code is distributed across projects. But when change arrives, they discover that the layers do not really isolate anything. A database change affects the UI. A web input model affects the application service. A persistence exception leaks into a workflow. A presentation need changes a domain object. A framework constraint shapes a use case.
The architecture has layers, but the layers are not doing enough work.
This article looks at that gap.
It explores situations where layers exist physically, but boundaries are weak or missing: pass-through services, framework types crossing layers, data models moving without translation, thin application services, persistence logic shaping behavior, tests that require too much infrastructure, and names that fail to protect responsibility.
The goal is not to add more layers.
The goal is to make existing layers mean something.1
A good boundary does not need to be heavy.
It does not always require many abstractions, separate models for everything, or strict ceremony around every call. But it does require clarity about responsibility.
A useful question is:
What does this layer prevent from leaking further into the system?
If the answer is unclear, the layer may only be a location.
A real boundary should protect meaning.
2. A Layer Is a Location; a Boundary Is a Responsibility
A layer gives code a place to live.
That is useful.
It can make a solution easier to navigate. It can separate web code from persistence code. It can keep application services away from UI details. It can make dependencies easier to reason about. It can help teams understand where new code should be placed.
But a layer by itself does not decide responsibility.
A folder named Application does not automatically mean the code inside it expresses application intent.
A project named Domain does not automatically mean business rules live there.
A namespace named Infrastructure does not automatically mean technical details are contained.
A layer is only a location until it protects a responsibility.
For example, an application service may live in the application layer:
public async Task<IActionResult> PublishAsync(
PublishArticleInputModel input,
CancellationToken ct)
{
var article = await _repository.GetByIdAsync(
input.ArticleId,
ct);
if (article is null)
{
return new NotFoundResult();
}
article.IsPublished = true;
await _repository.SaveAsync(article, ct);
return new RedirectToPageResult("/Admin/Articles");
}
This method may be physically placed in the application layer.
But it speaks several languages at once.
It accepts a web input model. It returns web framework results. It changes persistence-backed state directly. It decides navigation. It may be called an application service, but much of its shape is still defined by the web layer.
The layer exists.
The boundary is weak.
A stronger boundary would make the application service speak in application terms:
public async Task<PublishArticleResult> PublishAsync(
PublishArticleRequest request,
CancellationToken ct)
{
var article = await _repository.GetEditableArticleAsync(
request.ArticleId,
ct);
if (article is null)
{
return PublishArticleResult.NotFound();
}
if (!article.CanBePublished())
{
return PublishArticleResult.NotReady();
}
article.Publish(request.PublishedAt);
await _repository.SaveAsync(article, ct);
return PublishArticleResult.Succeeded();
}
This version may still use a repository.
It may still be called from a Razor Page, controller, API endpoint, background job, or test.
But the application boundary now does more than provide a location.
It translates a request into a use case.
It returns an application result instead of a web result.
It asks for data through an application-level method.
It uses application language such as CanBePublished, Publish, NotReady, and Succeeded.
The web layer can still translate the result into a redirect, validation message, status code, or page response.
But that translation happens at the web boundary.
The application service no longer needs to know how the result will be presented.
That is the difference between a layer and a boundary.
A layer organizes code.
A boundary protects meaning.2
A boundary decides which concepts are allowed to cross and which concepts must be translated.
For example:
- A web request should not automatically become an application command.
- A persistence entity should not automatically become a public response model.
- A database exception should not automatically become the language of a workflow.
- A UI view model should not automatically become a domain object.
- A framework attribute should not automatically become the only place a rule exists.
Each of those transitions may need translation.
Without translation, the layers may still exist physically, but their responsibilities remain entangled.
A useful boundary also limits vocabulary.
Inside the web layer, words such as request, route, model binding, page result, status code, and validation message may be appropriate.
Inside the persistence layer, words such as query, transaction, entity, migration, index, and constraint may be appropriate.
Inside the application layer, the language should usually be closer to the work the system performs: publish article, submit feedback, restore license, confirm download, reject invalid request, or return expected failure.
When the vocabulary of one layer dominates another, the boundary is usually weak.
That does not mean every layer needs to hide every detail.
Some details legitimately cross boundaries. An identifier may pass from a web request into an application request. A result may pass from an application service back to the UI. Data may move from persistence into a read model. A technical failure may be translated into an application outcome.
Information moves.
But responsibility should not move with it.
That distinction is important.
Passing an article id from the web layer to the application layer is normal.
Passing a Razor Page input model as the application command is different.
Returning an application result to a controller is normal.
Returning IActionResult from the application service is different.
Loading data from a repository is normal.
Letting a database query define the workflow rule for every caller is different.
A boundary is not meant to stop communication.
It is meant to make communication explicit.
A useful question is:
What does this layer translate before it passes control onward?
If the answer is “almost nothing”, the layer may be acting more like a corridor than a boundary.
Code enters from one side, passes through, and exits with most assumptions intact.
That may still add structure.
But it does not add much protection.
A real boundary should reduce the distance that external assumptions can travel.
It should help the system keep web concerns near the web, persistence concerns near persistence, UI concerns near UI, and application decisions near the application.
When a layer does that, it is more than a folder or project.
It becomes a responsibility boundary.
3. Pass-Through Layers Add Structure Without Protection
A common sign of a weak boundary is the pass-through layer.
A pass-through layer exists physically. It may have its own project, namespace, interface, service class, or method. It may follow the naming conventions of the architecture. It may even make the dependency graph look correct.
But it does very little.
It receives a call, forwards the same data to another layer, and returns the same kind of result back to the caller.
For example:
public async Task<Article?> GetArticleAsync(
string articleId,
CancellationToken ct)
{
return await _articleRepository.GetArticleAsync(
articleId,
ct);
}
This method may live in an application service.
But it does not express an application use case.
It does not translate input.
It does not protect a rule.
It does not name a meaningful question.
It does not convert persistence results into application results.
It only forwards the call.
That may be acceptable in a few places. Not every method needs complex behavior. Sometimes a thin method is a temporary step while a feature grows. Sometimes a simple query really is simple. Sometimes a layer is present to create a stable entry point for future behavior.
But if most of a layer looks like this, the layer may not be acting as a boundary.
It may be acting as a corridor.
The caller’s assumptions pass through unchanged.
The repository’s assumptions return unchanged.
The system has more structure, but not much more protection.
A pass-through layer becomes more problematic when it creates the appearance of separation without the benefits of separation.
For example, a controller may call an application service instead of a repository directly:
public async Task<IActionResult> OnGetAsync(
string articleId,
CancellationToken ct)
{
Article = await _articleService.GetArticleAsync(
articleId,
ct);
return Page();
}
At first glance, this looks better than calling persistence directly from the page.
But if GetArticleAsync simply returns the persistence entity, the page is still shaped by persistence. The application service has not translated the data into a read model, a result, or a question that belongs to the application.
The dependency has moved one step away.
The boundary has not become much stronger.
A more meaningful method would usually describe what the caller actually needs:
public async Task<ArticleDetailsResult> GetArticleDetailsAsync(
string articleId,
CancellationToken ct)
{
var article = await _articleRepository.GetPublishedArticleAsync(
articleId,
ct);
if (article is null)
{
return ArticleDetailsResult.NotFound();
}
return ArticleDetailsResult.Found(
new ArticleDetails(
article.Title,
article.Summary,
article.ContentMarkdown,
article.PublishedAt));
}
This method does more than forward a call.
It names the application question.
It asks the repository for a meaningful persistence query.
It translates a missing article into an application result.
It returns a shape that fits the use case instead of exposing the persistence entity directly.
The method is still simple.
But it now protects something.
Pass-through layers also appear around commands.
For example:
public async Task SaveAsync(
Article article,
CancellationToken ct)
{
await _articleRepository.SaveAsync(article, ct);
}
This method may be technically correct.
But it does not say what operation is being performed.
Is the article being published?
Edited?
Archived?
Scheduled?
Restored?
Imported?
If the application layer only exposes generic persistence-like operations, it may not be expressing application behavior. It may only be giving the repository another name.
A stronger application boundary usually names the operation:
public async Task<PublishArticleResult> PublishAsync(
PublishArticleRequest request,
CancellationToken ct)
{
var article = await _articleRepository.GetEditableArticleAsync(
request.ArticleId,
ct);
if (article is null)
{
return PublishArticleResult.NotFound();
}
if (!article.CanBePublished())
{
return PublishArticleResult.NotReady();
}
article.Publish(request.PublishedAt);
await _articleRepository.SaveAsync(article, ct);
return PublishArticleResult.Succeeded();
}
The difference is not only that the method contains more code.
The difference is that it owns a responsibility.
It defines the application operation.
It decides what data is needed.
It protects expected outcomes.
It gives the caller application language instead of persistence language.
A pass-through layer can also hide missing responsibility by spreading behavior across both sides of the call.
The controller may validate part of the request.
The application service may forward it.
The repository may apply part of the rule.
The UI may decide which result matters.
No single layer clearly owns the decision.
The system has layers, but the responsibility is still scattered.
That can make the code harder to reason about than a simpler structure with fewer layers.
A weak layer can add indirection without adding clarity.
That is why pass-through code should be judged carefully.
The question is not whether every method has enough lines.
A small method can still be meaningful if it gives something the right name or protects an important boundary.
The question is whether the method changes the language or responsibility of the call.
A useful question is:
If this layer disappeared, what important responsibility would be lost?
If the answer is “not much”, the layer may not be carrying its weight.
That does not always mean it should be removed immediately.
Sometimes it means the boundary needs to become more explicit.
The layer may need to translate framework input into application requests.
It may need to return application results instead of technical results.
It may need to expose named operations instead of generic CRUD methods.
It may need to own expected outcomes instead of letting exceptions or persistence details leak upward.
It may need to decide what belongs to the workflow and what belongs to the caller.
Pass-through layers are not always wrong.
But they are a warning sign.
They show that the architecture may have structure without enough protection.
A real boundary should do more than pass data along.
It should clarify responsibility before the call moves further into the system.
4. Framework Types Can Cross Layers Quietly
Framework types often enter an application for good reasons.
A web framework needs request models, route values, action results, model state, validation attributes, claims, headers, status codes, and binding behavior. A persistence framework needs entities, contexts, query types, tracking behavior, migrations, and database exceptions. A UI framework needs commands, events, bindings, view models, controls, and presentation state.
Those types belong somewhere.
They are part of how the application connects to the outside world.
The problem begins when those framework types move beyond the boundary where they belong.
This often happens quietly.
A controller passes its input model directly to an application service. A service returns IActionResult because the first caller is a web endpoint. A repository exposes IQueryable because the caller wants flexible filtering. A persistence entity is returned to the UI because it already contains the data. A validation attribute becomes the only expression of a rule.
Each decision can seem practical.
The type already exists. It already has the needed properties. Reusing it avoids mapping. The feature can be completed with less code.
But the type brings its framework assumptions with it.
For example:
public async Task<IActionResult> SubmitAsync(
SubmitFeedbackInputModel input,
CancellationToken ct)
{
if (!ModelState.IsValid)
{
return Page();
}
await _feedbackService.SubmitAsync(input, ct);
return RedirectToPage("/Feedback/Thanks");
}
This may be acceptable inside a Razor Page or controller.
But if similar types and results begin to appear inside the application layer, the application boundary becomes weaker.
The application service starts to know about web input models, model state, page results, redirects, or HTTP status codes. Those concepts describe how the operation is delivered, not what the operation means.
A stronger boundary usually translates the framework type before crossing into the application:
var result = await _feedbackService.SubmitAsync(
new SubmitFeedbackRequest(
Input.Message,
Input.EmailAddress),
ct);
The web layer still handles model binding.
It still handles validation display.
It still decides whether to render a page, redirect, or return a status code.
But the application service receives an application request.
That request can be used by a Razor Page, API endpoint, desktop application, background job, or test without carrying web framework assumptions with it.
The same issue appears with return types.
For example:
public async Task<IActionResult> RestoreLicenseAsync(
RestoreLicenseRequest request,
CancellationToken ct)
{
// Restore license.
}
This method may live in an application service, but its result belongs to the web framework.
That means the use case is no longer only expressing application outcomes. It is also deciding HTTP or page behavior.
A better application boundary usually returns an application result:
public async Task<RestoreLicenseResult> RestoreLicenseAsync(
RestoreLicenseRequest request,
CancellationToken ct)
{
// Restore license.
}
The web layer can then translate that result:
return result.Status switch
{
RestoreLicenseStatus.Succeeded => RedirectToPage("/License/Restored"),
RestoreLicenseStatus.NotFound => NotFound(),
RestoreLicenseStatus.TooManyDevices => Page(),
_ => StatusCode(StatusCodes.Status500InternalServerError)
};
This keeps the delivery mechanism near the delivery boundary.
The application service can focus on the workflow.
Persistence framework types can leak in a similar way.
For example:
public IQueryable<Article> GetArticles()
{
return _dbContext.Articles;
}
This gives callers flexibility, but it also gives them responsibility for persistence behavior.
The caller now needs to understand query translation, execution timing, tracking, includes, provider limitations, and database-specific behavior. A method in one layer has exposed the language of another layer.
A stronger boundary names the question:
Task<IReadOnlyList<ArticleSummary>> GetPublishedArticleSummariesAsync(
CancellationToken ct);
The repository implementation may still use DbContext, IQueryable, tracking, and database-specific queries internally.
But those details do not become the caller’s language.
The caller asks an application question.
The persistence layer answers it.
Framework types can also cross through exceptions.
A database exception may become the only way the application knows that a slug already exists. An HTTP client exception may become the only way the application knows that an email could not be sent. A JSON parsing exception may become the only way the application knows that input was invalid.
Technical failures matter.
But expected application outcomes should usually be translated before they travel too far.
A database constraint violation can become SlugAlreadyExists.
A provider timeout can become EmailCouldNotBeSent.
An expired token can become ConfirmationExpired.
A missing row can become ArticleNotFound.
The technical detail can still be logged.
It can still be available for diagnostics.
But the application does not need to speak in framework exceptions when it is describing expected outcomes.
Framework attributes are another quiet path across boundaries.
An input model may contain validation attributes, display attributes, authorization metadata, serialization names, or persistence mapping details. If that model is reused across layers, the attributes move with it.
The application may then depend on rules that exist only because a framework interprets those attributes.
That can be fragile.
A boundary should make it clear whether a rule belongs to the framework edge or to the application itself.
For example, [Required] may help a web form display a validation message.
But if a message is required for the operation to be valid, the application service should also express that requirement in application language.
A useful question is:
Would this type still make sense if the framework that created it disappeared?
If the answer is no, the type probably belongs near that framework.
That does not mean it is wrong.
It only means it should be translated before it crosses into a layer that should not depend on the framework.
Another useful question is:
Is this layer receiving application language, or framework language?
If the answer is framework language, the boundary may be too weak.
Framework types are not dangerous because they are framework types.
They are dangerous when they start defining parts of the system that should remain independent of the framework.
A web boundary should know about web concepts.
A persistence boundary should know about persistence concepts.
A UI boundary should know about UI concepts.
But the application core should not be shaped by those details just because the types were convenient to reuse.
Framework types should be useful at the edges.
They should not quietly become the language of the whole system.3
5. Data Models Can Move Without Translation
Data models often move through a system more easily than they should.
A model may begin in one place for a specific purpose. It may represent a database row, an API request, a form input, a view model, a read model, or a domain concept. Later, another layer needs similar data, and the model is reused.
That can seem harmless.
The properties are already there. The shape is familiar. Mapping would add more code. Reusing the existing model feels efficient.
But when a model crosses a boundary without translation, it carries its original assumptions with it.
That is where the problem begins.
For example, a persistence entity may be designed to fit storage:
public sealed class ArticleEntity
{
public string Id { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string ContentMarkdown { get; set; } = string.Empty;
public bool IsPublished { get; set; }
public DateTimeOffset? PublishedAt { get; set; }
public byte[] RowVersion { get; set; } = [];
}
This model may be appropriate inside the persistence layer.
It contains mutable properties. It may follow database naming. It may include concurrency fields. It may contain values needed for storage but not for presentation. It may be shaped by an ORM, table structure, migration history, or indexing strategy.
Those are valid persistence concerns.
But if the same model is returned directly to a page, API endpoint, or application service, those persistence concerns move with it.
The caller now sees more than the information it actually needs.
A public article page may receive RowVersion.
An API response may expose IsPublished even though only published articles should be returned.
A UI may depend on nullable persistence state instead of a clearer application outcome.
A test may need to populate storage-specific fields that do not matter to the behavior being tested.
The data has moved.
But it has not been translated.
A stronger boundary usually creates a model that fits the receiving context:
public sealed class PublicArticleDetails
{
public string Slug { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string ContentMarkdown { get; init; } = string.Empty;
public DateTimeOffset PublishedAt { get; init; }
}
This model is not just a smaller version of the entity.
It speaks in the language of the public article view.
It includes what the reader-facing feature needs and excludes storage details that do not belong there.
The mapping may look simple:
public static PublicArticleDetails ToPublicDetails(
ArticleEntity article)
{
return new PublicArticleDetails
{
Slug = article.Slug,
Title = article.Title,
ContentMarkdown = article.ContentMarkdown,
PublishedAt = article.PublishedAt!.Value
};
}
But the mapping is doing more than copying properties.
It is marking a boundary.
It says that the persistence shape is not automatically the presentation shape.
It also gives the code a place to express assumptions. For example, a public article detail model may require PublishedAt to be present because unpublished articles should never reach this point.
That assumption belongs in the boundary.
Without translation, the receiving layer must understand the persistence model’s nullable state and decide what it means.
That weakens the boundary.
The same problem can happen in the opposite direction.
A web input model may be designed for binding and validation display:
public sealed class PublishArticleInputModel
{
[Required]
public string ArticleId { get; set; } = string.Empty;
public bool NotifySubscribers { get; set; }
}
This model may be fine for a Razor Page.
But passing it directly into the application layer makes the use case depend on web-specific concerns: mutable properties, validation attributes, binding assumptions, and display-oriented shape.
A clearer application request usually expresses the operation directly:
public sealed record PublishArticleRequest(
string ArticleId,
bool NotifySubscribers);
The web layer can create this request from its input model.
That small translation keeps the application service independent of the web framework.
It also makes the use case easier to call from tests, background jobs, APIs, or other entry points.
Data models can also leak meaning when one model is used for several different read scenarios.
For example, an ArticleSummary model may be reused for a public article list, an admin dashboard, a search result, and a newsletter preview.
At first, the fields may overlap.
But each scenario may have different reasons to change.
The public list cares about reader-facing title, summary, and publication date.
The admin dashboard may care about draft state, review status, and editing permissions.
Search results may care about excerpts and relevance.
Newsletter previews may care about subject lines and delivery status.
If one model serves all of these, it will gradually grow fields that only some callers use.
That makes the model harder to understand and easier to misuse.
Separate models can be clearer:
public sealed class PublicArticleSummary
{
public string Title { get; init; } = string.Empty;
public string Summary { get; init; } = string.Empty;
public DateTimeOffset PublishedAt { get; init; }
}
public sealed class AdminArticleQueueItem
{
public string Id { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string ReviewStatus { get; init; } = string.Empty;
public bool CanEdit { get; init; }
}
These models may duplicate a few properties.
That duplication is not necessarily waste.
It allows each boundary to use a shape that fits its responsibility.
A useful distinction is:
Moving data is not the same as moving a model.
Data often needs to move through the system.
An article id may come from a route. Article content may be loaded from storage. A result may be shown on a page. A command may be created from user input.
That movement is normal.
But the model used in one context should not automatically become the model used everywhere else.
Translation protects boundaries because it allows each layer to decide what the data means in its own language.
It also limits the spread of accidental assumptions.
A persistence model can change because the database changes.
A view model can change because the UI changes.
An application request can change because the use case changes.
A public response can change because the contract changes.
Those changes should not automatically force every other layer to change with them.
A useful question is:
Is this model crossing the boundary because it represents the right concept, or because it already has the right properties?
If it represents the right concept, sharing it may be acceptable.
If it only has the right properties, translation may be safer.
Another useful question is:
What assumptions does this model bring with it?
If it brings framework attributes, persistence concerns, UI state, transport formatting, nullable storage details, or fields irrelevant to the receiving context, it probably needs translation.
A real boundary does not merely pass models along.
It translates data into the language of the next responsibility.
6. Application Services Can Become Thin Routing Code
Application services often look like the natural place for a boundary.
They sit between the delivery mechanism and the deeper parts of the system. A controller, Razor Page, API endpoint, UI command, background job, or scheduled task can call an application service instead of reaching directly into persistence or domain objects.
That structure can be useful.
It gives the application a place to name use cases, coordinate work, enforce rules, translate technical failures, and return meaningful outcomes.
But application services do not become boundaries automatically.
They can also become thin routing code.
A thin routing service receives a request, calls another component, and returns whatever came back. It may add logging, cancellation tokens, or simple null checks, but it does not really own the operation.
For example:
public async Task<Article?> GetArticleAsync(
string articleId,
CancellationToken ct)
{
return await _articleRepository.GetByIdAsync(
articleId,
ct);
}
This may be acceptable for a very simple read operation.
But if most application services look like this, the application layer may not be expressing much application behavior.
It may only be forwarding calls.
The same can happen with commands:
public async Task SaveArticleAsync(
Article article,
CancellationToken ct)
{
await _articleRepository.SaveAsync(article, ct);
}
This method does not say what the user or workflow is trying to do.
It only says that an article is being saved.
But saving is usually a persistence activity, not an application use case.
The application operation may be more specific:
- publish an article,
- schedule an article,
- update article content,
- archive an article,
- restore an article,
- approve a draft,
- or reject a submitted change.
Those operations may all involve saving an article.
But they are not the same application behavior.
If the application service exposes only generic routing methods, the real decisions may move elsewhere. The controller may decide when saving is allowed. The UI may decide which fields can change. The repository may decide which state transitions are valid. The database may reject invalid combinations through constraints.
The application layer is present.
But the boundary is weak.
A stronger application service usually gives the workflow a name:
public async Task<PublishArticleResult> PublishAsync(
PublishArticleRequest request,
CancellationToken ct)
{
var article = await _articleRepository.GetEditableArticleAsync(
request.ArticleId,
ct);
if (article is null)
{
return PublishArticleResult.NotFound();
}
if (!article.CanBePublished())
{
return PublishArticleResult.NotReady();
}
article.Publish(request.PublishedAt);
await _articleRepository.SaveAsync(article, ct);
return PublishArticleResult.Succeeded();
}
This method is still not complicated.
But it does several important things.
It names the operation.
It asks for data in application language.
It handles an expected missing article.
It checks whether the article can be published.
It changes state through an application-level method.
It returns an application result.
The method creates a boundary because it owns the workflow.
That does not mean every application service method must contain many rules.
Some use cases are simple. Some operations are mostly coordination. Some behavior may live inside domain objects, policies, validators, or specialized services. An application service can remain small and still be meaningful.
The issue is not size.
The issue is whether the service protects responsibility.
A small method can be a good boundary if it translates input, names the use case, chooses the correct collaborator, or returns an application outcome.
A large method can still be a poor boundary if it mostly mixes framework concerns, persistence details, and UI decisions.
Thin routing code becomes especially risky when it gives a false sense of separation.
A controller calls an application service, so the system appears layered.
But if the service simply forwards web input to a repository and returns persistence entities back to the controller, the architecture has only added distance. It has not added protection.
For example:
public async Task<ArticleEntity?> GetAsync(
GetArticleInputModel input,
CancellationToken ct)
{
return await _articleRepository.GetAsync(
input.ArticleId,
ct);
}
This method crosses several boundaries without translation.
It accepts a web input model.
It returns a persistence entity.
It exposes a generic operation.
It does not express what the application is trying to achieve.
The code has an application service in the middle, but the application boundary is not doing much work.
A more meaningful version might look like this:
public async Task<ArticlePreviewResult> GetPreviewAsync(
ArticlePreviewRequest request,
CancellationToken ct)
{
var article = await _articleRepository.GetEditableArticleAsync(
request.ArticleId,
ct);
if (article is null)
{
return ArticlePreviewResult.NotFound();
}
return ArticlePreviewResult.Found(
new ArticlePreview(
article.Title,
article.ContentMarkdown));
}
This method still delegates to the repository.
But it does not merely route.
It names the use case: get a preview.
It translates the input into an application request.
It chooses an application-level repository question.
It returns a result shaped for the operation.
That is a boundary.
Application services can also become thin routing code when all decisions are pushed into infrastructure.
For example, a service may call a repository method such as:
var article = await _articleRepository.GetPublishableArticleAsync(
request.ArticleId,
ct);
That may be fine if “publishable article” is a persistence query that supports a clearly named application rule elsewhere.
But if the repository query is the only place where publishability is defined, the application service is no longer owning the workflow decision. The persistence layer is shaping the application behavior.
The application service may then only route success and failure.
That weakens the boundary.
A useful application service does not need to contain every rule directly, but it should make ownership visible.
It can delegate to an article method, a policy, a validator, or a domain service:
if (!_publishingPolicy.CanPublish(article, request.UserId))
{
return PublishArticleResult.NotAllowed();
}
The important part is that the application service speaks in application language and coordinates application-level decisions.
It should not merely pass technical shapes through the system.
A useful question is:
Does this service describe a use case, or does it only route data to another component?
If it describes a use case, it can be a meaningful boundary.
If it only routes data, the real boundary may be missing.
Another useful question is:
Would a caller understand the application operation by reading this method name and result type?
If the method is named SaveAsync, GetAsync, HandleAsync, or ProcessAsync, the answer may be no.
If the method is named PublishAsync, RestoreLicenseAsync, SubmitFeedbackAsync, or ConfirmDownloadAsync, the operation is easier to see.
Names are not enough by themselves.
But vague names are often a sign that the application service has not yet claimed a clear responsibility.
Application services should not be artificial corridors between controllers and repositories.
They should be places where application operations are named, coordinated, protected, and translated.
When they do that, they become real boundaries.
When they only route data, the layer is present, but the boundary is still weak.
7. Persistence Logic Can Still Shape Application Behavior
Separating persistence code into its own layer is useful.
It keeps database access, queries, mappings, transactions, migrations, and storage-specific details away from the delivery layer. It can make the system easier to test, easier to change, and easier to reason about.
But a separate persistence layer does not automatically mean persistence is contained.
Persistence logic can still shape application behavior if the application layer depends too strongly on how data is stored, queried, filtered, or constrained.
This often happens when repositories expose storage-oriented operations instead of application-oriented questions.
For example:
public interface IArticleRepository
{
Task<Article?> GetByIdAsync(
string articleId,
CancellationToken ct);
Task SaveAsync(
Article article,
CancellationToken ct);
}
This interface is not necessarily wrong.
Many applications need simple retrieval and saving operations.
But by itself, it does not say much about application behavior.
If the application service calls GetByIdAsync, then applies some rules, then calls SaveAsync, the boundary may still be clear.
But if every workflow is built from generic persistence operations, the application layer may become responsible for repeatedly reconstructing the meaning of the data.
It must remember which articles are editable, which are visible, which are publishable, which are archived, which require scheduling, and which should be excluded from public display.
That can lead to duplicated filtering logic.
It can also lead to persistence-shaped application code.
For example:
var article = await _articleRepository.GetByIdAsync(
request.ArticleId,
ct);
if (article is null ||
article.IsArchived ||
article.IsDeleted ||
article.PublishedAt is not null)
{
return PublishArticleResult.NotFound();
}
This may work.
But the application service is now assembling a persistence-oriented interpretation of what it means to find an article for publishing.
Is the article not found?
Is it archived?
Is it already published?
Is it not editable?
Those may be different application outcomes.
If they are all collapsed into one storage-shaped check, the workflow becomes less clear.
A better repository method can name the application question:
Task<Article?> GetEditableArticleAsync(
string articleId,
CancellationToken ct);
That does not mean the repository owns the publishing rule.
It means the persistence boundary answers a meaningful retrieval question.
The application service can still decide whether the article can be published:
var article = await _articleRepository.GetEditableArticleAsync(
request.ArticleId,
ct);
if (article is null)
{
return PublishArticleResult.NotFound();
}
if (!article.CanBePublished())
{
return PublishArticleResult.NotReady();
}
This version is easier to read because the query and the workflow decision have clearer names.
The repository answers: can this article be retrieved as an editable article?
The application answers: can this editable article be published now?
Those responsibilities are related, but not identical.
Persistence logic can also shape behavior when query filters become the only place where application meaning exists.
For example, a repository may contain:
return await _dbContext.Articles
.Where(article => article.IsPublished)
.Where(article => !article.IsArchived)
.Where(article => article.PublishedAt <= now)
.OrderByDescending(article => article.PublishedAt)
.ToListAsync(ct);
This may be the right query for public article display.
But the meaning should be visible.
If this query defines what “visible to readers” means, the repository method should say so:
Task<IReadOnlyList<ArticleSummary>> GetVisibleArticleSummariesAsync(
DateTimeOffset now,
CancellationToken ct);
The query can remain in the persistence layer.
But the application language should not disappear inside a chain of filters.
Naming the query makes the boundary stronger.
It also gives tests and callers a clearer concept to refer to.
Persistence logic can also shape behavior through database constraints.
A unique index may prevent duplicate slugs.
A foreign key may prevent invalid relationships.
A required column may prevent missing values.
A concurrency token may detect conflicting edits.
Those constraints are useful.
They protect the integrity of stored data.
But they should not always be the only place where the application rule exists.
For example, if a duplicate slug is an expected outcome of creating an article, the application should not only expose a database exception.
It should translate the failure:
return CreateArticleResult.Failed(
CreateArticleFailureReason.SlugAlreadyExists);
The database still enforces consistency.
But the application expresses the meaning.
That distinction matters because callers should not need to understand database constraint names to understand workflow outcomes.
A web page may show a validation message.
An API may return a conflict response.
A background job may skip the item and continue.
A test may assert the exact failure reason.
All of those callers benefit from an application result rather than a raw persistence failure.
Persistence behavior can also leak through tracking and lifecycle assumptions.
For example, code may rely on an ORM automatically tracking changes:
article.Title = request.Title;
await _articleRepository.SaveAsync(article, ct);
This may be fine inside infrastructure or within a repository implementation.
But if the application layer becomes dependent on tracking behavior, it may become harder to change persistence mechanisms later. It may also become less clear which operations intentionally modify state.
A more explicit method can make the application decision clearer:
article.UpdateContent(
request.Title,
request.ContentMarkdown);
The persistence layer can still decide how the change is stored.
But the application code now describes the operation rather than depending only on property mutation.
That does not mean every application needs a rich domain model.
Simple CRUD features may reasonably update properties directly.
The important question is whether important workflow behavior is being expressed or merely implied by persistence mechanics.
Persistence logic can also influence what tests are possible.
If application behavior depends on query translation, tracking, database exceptions, or provider-specific behavior, many application tests may require a database.
Some tests should require a database.
Repository tests, mapping tests, migration tests, concurrency tests, and provider-specific query tests belong near the persistence boundary.
But ordinary workflow tests should not always need persistence infrastructure just to verify application decisions.
If publishing an article requires a full database setup because the publishability rule exists only inside a query, the boundary may be weak.
A useful question is:
Is the persistence layer answering a storage question, or is it defining an application decision?
Sometimes the answer is both.
That is where naming and translation matter.
A repository may efficiently retrieve “visible articles”, “editable articles”, or “licenses eligible for restore”. Those are application-shaped queries implemented by persistence.
But the application should still own the meaning of the workflow around those results.
Another useful question is:
Could another persistence mechanism support the same application operation without changing the application language?
If the answer is no, persistence details may have leaked too far.
The goal is not to hide the database completely.
The database is real.
Queries matter.
Constraints matter.
Transactions matter.
Performance matters.
But persistence should support application behavior rather than quietly define it.
A strong persistence boundary gives storage operations application-level names, translates expected technical failures, and keeps storage mechanics from becoming the language used by the rest of the system.
When persistence logic shapes the application too strongly, the layer is present.
But the boundary is not doing enough work.4
8. Boundary Leaks Show Up in Tests
Weak boundaries often become visible in tests before they become obvious in production code.
The production code may look organized. It may have separate projects, services, repositories, models, and handlers. The dependency graph may appear reasonable. The names may suggest that the system is layered.
But tests reveal what a piece of code really depends on.
If a simple application rule requires a large framework setup to test, the boundary may be weak.
For example, suppose a test needs to verify that an article without content cannot be published. That is an application decision. It should usually be possible to test it without starting a web host, configuring model binding, creating a real database, constructing a Razor Page, or inspecting a framework-specific result.
A direct application test might look like this:
[Fact]
public async Task PublishAsync_ReturnsNotReady_WhenContentIsMissing()
{
var repository = new FakeArticleRepository();
var article = new Article
{
Id = "article-1",
Title = "Draft article",
ContentMarkdown = string.Empty
};
repository.Add(article);
var service = new ArticlePublishingService(repository);
var result = await service.PublishAsync(
new PublishArticleRequest(article.Id),
CancellationToken.None);
Assert.Equal(PublishArticleStatus.NotReady, result.Status);
}
This test speaks in application language.
It creates an article. It calls a publishing operation. It checks an application result.
It does not need to understand how a web page displays the result.
It does not need to know how a database stores the article.
It does not need to inspect HTTP status codes, redirects, model state, or ORM tracking behavior.
That is a sign that the boundary is doing useful work.
By contrast, if the same rule can only be tested through a Razor Page handler, the test may look less direct. It may need to prepare an input model, configure services, create a page model, populate ModelState, call OnPostAsync, inspect IActionResult, and verify which page or redirect was returned.
That may be a valid web test.
But it is a heavy way to test a publishing rule.
The test is no longer only about application behavior. It is also about the framework path used to reach that behavior.
That usually means the rule is too closely attached to the delivery mechanism.
The same problem appears when persistence details leak across boundaries.
If testing whether an article is visible requires a real database because the visibility rule exists only inside a query, the application concept may be hidden in persistence logic.
Some database tests are necessary.
Repository queries, mappings, migrations, constraints, transactions, concurrency behavior, and provider-specific query translation should be tested with appropriate persistence infrastructure.
But ordinary application behavior should not always require that infrastructure.
A useful separation is:
- application tests verify application decisions,
- web tests verify web translation,
- persistence tests verify storage behavior,
- UI tests verify presentation behavior,
- integration tests verify that the pieces work together.
Each type of test has value.
The problem appears when every test must become an integration test because the boundaries do not isolate responsibilities.
That creates several practical costs.
Tests become slower.
They become harder to set up.
They fail for reasons unrelated to the behavior being tested.
They require more mocking, more fixtures, more framework configuration, and more knowledge of implementation details.
They also become harder to read.
A test that should say “an article without content cannot be published” may instead say “when this page handler receives this model and this repository returns this entity and this framework result is produced, then the operation failed.”
The behavior is still there, but it is harder to see.
Weak boundaries also make tests brittle.
If a test of an application rule breaks because a route changed, a page result changed, a database provider changed, or a view model property was renamed, the test is probably coupled to the wrong layer.
That does not mean the test is useless.
It may be testing an integration path.
But it should not be the only test protecting the application rule.
A real boundary gives tests a simpler entry point.
A use case can be called directly.
A policy can be tested directly.
A result type can be asserted directly.
A mapper can be tested near the boundary.
A repository query can be tested separately.
That makes failures more informative.
If an application test fails, the application behavior changed.
If a web test fails, the web translation changed.
If a persistence test fails, the storage behavior changed.
That separation helps developers understand the system.
Tests can also reveal pass-through layers.
If a test of an application service needs to mock a repository and then only verifies that the repository was called with the same values, the application service may not be adding much responsibility.
For example:
[Fact]
public async Task GetArticleAsync_CallsRepository()
{
var repository = new MockArticleRepository();
var service = new ArticleService(repository);
await service.GetArticleAsync("article-1", CancellationToken.None);
Assert.Equal("article-1", repository.LastRequestedArticleId);
}
This test may be technically correct.
But it does not describe much application behavior.
It mostly verifies routing.
That can be a sign that the service is present as a layer, but not yet acting as a boundary.
A more meaningful test would verify an application outcome:
[Fact]
public async Task GetArticleDetailsAsync_ReturnsNotFound_WhenArticleIsMissing()
{
var repository = new FakeArticleRepository();
var service = new ArticleQueryService(repository);
var result = await service.GetArticleDetailsAsync(
"missing-article",
CancellationToken.None);
Assert.Equal(ArticleDetailsStatus.NotFound, result.Status);
}
This test still uses a fake repository.
But it verifies the application meaning of a missing article, not only that a call was forwarded.
That is a stronger signal.
A useful question is:
What must I construct before I can test this decision?
If the answer includes a full framework pipeline, a database, a UI component, or a transport model for a simple application rule, the boundary may not be strong enough.
Another useful question is:
Does this test fail because the behavior changed, or because a delivery detail changed?
Good boundaries help tests fail for clearer reasons.
They allow application behavior to be tested close to where the behavior belongs, while framework-specific tests remain near the framework boundary.
This does not remove the need for integration tests.
Integration tests are important because boundaries can be correct individually and still fail when connected. A web endpoint may map the wrong field. A repository may return the wrong read model. A middleware component may translate an exception incorrectly. A page may display the wrong result.
Those paths should be tested.
But integration tests should not carry the entire burden of proving application behavior.
When boundaries are clear, tests can be layered in the same way as the code.
Application tests protect application meaning.
Boundary tests protect translation.
Integration tests protect the assembled path.
That structure makes the system easier to maintain.
When boundary leaks show up in tests, they should not be treated only as testing problems.
They are often design feedback.
A difficult test may be saying that a responsibility is in the wrong place, that a model crosses too far, that a result type belongs to the wrong layer, or that a rule has no clear owner.
Listening to that feedback can improve both the tests and the design.
A strong boundary makes code easier to test because it makes responsibility easier to reach.
9. Naming Is Often the First Boundary
Boundaries are not created only by projects, folders, interfaces, or dependency rules.
They are also created by names.
A name can clarify what a piece of code is responsible for. It can show which layer owns a concept. It can reveal whether an operation belongs to the application, the UI, persistence, infrastructure, or transport. It can make a boundary visible before the structure becomes complicated.
A weak name often hides a weak boundary.
For example:
public async Task SaveAsync(
Article article,
CancellationToken ct)
{
await _articleRepository.SaveAsync(article, ct);
}
This method may be useful at the persistence boundary.
But if it is the main application operation, the name is too generic.
Save what?
Save because an editor updated content?
Save because the article was published?
Save because it was scheduled?
Save because it was archived?
Save because an import updated metadata?
Each of those operations may involve saving an article, but saving is not the application meaning. It is only the storage action that happens after the decision has been made.
A stronger name can create a stronger boundary:
public async Task<PublishArticleResult> PublishAsync(
PublishArticleRequest request,
CancellationToken ct)
{
// Publish article.
}
This method names the operation.
The caller does not need to infer the use case from a generic storage verb.
The method also creates a place where publishing rules, expected failures, and result translation can belong.
That is why naming matters.
A name is not only a label.
It is a claim about responsibility.
Generic names often indicate that the responsibility is unclear:
HandleAsync();
ProcessAsync();
ExecuteAsync();
SaveAsync();
GetAsync();
Validate();
ApplyRules();
PrepareData();
These names are not always wrong.
A command handler may reasonably have a method named HandleAsync. A repository may reasonably have methods named GetAsync or SaveAsync. A low-level utility may reasonably process data.
But when these names appear at an application boundary, they should be examined carefully.
They may be hiding a missing use case name.
For example:
await _articleService.ProcessAsync(input, ct);
This tells the reader very little.
By contrast:
await _articlePublishingService.PublishAsync(request, ct);
or:
await _licenseRestoreService.RestoreAsync(request, ct);
or:
await _feedbackSubmissionService.SubmitAsync(request, ct);
These calls are more specific.
They show what the application is trying to do.
Specific names also make boundary leaks easier to notice.
If a method named PublishAsync accepts a Razor Page input model, the mismatch becomes visible. If a method named RestoreLicenseAsync returns IActionResult, the result type looks suspicious. If a method named GetVisibleArticlesAsync exposes IQueryable, the persistence detail feels out of place.
The name helps reveal the boundary problem.
Weak names do the opposite.
A method named ProcessAsync can accept almost anything and return almost anything without looking obviously wrong.
That flexibility is not always helpful.
It can hide the fact that the method has no clear owner, no clear result, and no clear boundary.
Names also matter for models.
A model named ArticleModel does not tell the reader much.
Is it a persistence entity?
A public read model?
An admin edit model?
A search result?
A newsletter preview?
An API response?
A domain object?
The vague name makes it easier for the model to be reused in too many places.
A more specific name protects the boundary:
public sealed class PublicArticleSummary
{
public string Title { get; init; } = string.Empty;
public string Summary { get; init; } = string.Empty;
public DateTimeOffset PublishedAt { get; init; }
}
or:
public sealed class ArticlePublishingQueueItem
{
public string Id { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string ReviewStatus { get; init; } = string.Empty;
}
These names say where the models belong.
They also make inappropriate reuse more visible.
If an admin page starts depending on PublicArticleSummary, the name raises a question. If a public page starts receiving ArticlePublishingQueueItem, the mismatch is obvious.
That is useful.
A good name can make incorrect coupling feel uncomfortable.
Names also help distinguish information from responsibility.
For example, ArticleId can move across several boundaries. It is just an identifier.
But PublishArticleRequest says more. It describes a command-like application request.
ArticleEntity says the object belongs to persistence.
ArticleDetailsViewModel says the object belongs to presentation.
ArticleDetailsResult says the object represents an application outcome.
Those names help each layer speak in its own language.
When names are too generic, the same type can drift across boundaries without resistance.
For example:
public sealed class ArticleDto
{
public string Id { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
}
Dto may be appropriate in some contexts, especially at transport boundaries.
But if everything becomes a Dto, the name stops protecting anything.
The reader still has to ask where the type belongs and why it exists.
A stronger name often makes the purpose clearer than the technical pattern.
Naming is also important for results.
A result named OperationResult may be reusable, but it may not explain much:
public sealed class OperationResult
{
public bool Succeeded { get; init; }
public string? ErrorCode { get; init; }
}
A result like this can become another pass-through shape.
Different workflows may use it for different meanings. Error codes may become string-based conventions rather than explicit result types. Callers may need to know which codes apply to which operation.
A more specific result protects the application language:
public sealed record PublishArticleResult(
PublishArticleStatus Status);
or:
public enum PublishArticleStatus
{
Succeeded,
NotFound,
NotReady,
NotAllowed
}
This result may be less generic, but it is easier to understand and test.
It says what outcomes matter for publishing.
It gives the boundary a vocabulary.
That vocabulary can then be translated by the delivery layer into redirects, status codes, validation messages, dialogs, or logs.
A useful question is:
Does this name describe what the application means, or only what the code mechanically does?
If the name describes only a mechanical action, the boundary may still be weak.
Another useful question is:
Would this name still make sense if the implementation changed?
If PublishAsync changes from using JSON files to SQL Server, the name still makes sense.
If GetFromDatabaseAsync changes to call an API, the name no longer fits.
That can reveal whether the name belongs to the application concept or the current technical mechanism.
Naming is not a substitute for design.
A well-named method can still be in the wrong layer. A specific model can still carry the wrong responsibilities. A clear result type can still be translated poorly.
But naming is often the first sign of whether the boundary exists.
Good names make responsibilities visible.
They make leaks uncomfortable.
They make tests easier to read.
They make code review easier because reviewers can ask whether the implementation matches the name.
They also make future change easier because developers can find the place where a decision belongs.
When a layer is present but the boundary is weak, improving names is often a practical first step.
Rename generic operations into use cases.
Rename vague models into purpose-specific shapes.
Rename technical results into application outcomes.
Rename persistence-shaped queries into application questions.
Those changes may seem small.
But they can shift the design.
They create places where responsibility can gather.
A boundary often begins when the code finally has a name for what it is protecting.
10. Strengthening Boundaries Gradually
When a layer is present but the boundary is weak, the solution is usually not to redesign the entire architecture at once.
That can create more disruption than clarity.
A system may already have controllers, application services, repositories, models, view models, infrastructure components, and tests. Some boundaries may be reasonably clear. Others may be pass-through structures. Some framework types may have crossed too far. Some models may be reused in too many places. Some services may be thin routing code because the real decisions are still scattered.
Trying to fix all of that in one large change is risky.
It is usually better to strengthen boundaries gradually.
Start with one feature where change feels harder than it should.
That is often where the missing boundary is easiest to see.
Maybe a database change affects a page directly.
Maybe a web input model is passed into an application service.
Maybe a repository returns a persistence entity to the UI.
Maybe a use case returns IActionResult.
Maybe a test for an application rule requires a web host and a database.
Maybe a method name is so generic that nobody is sure where the responsibility belongs.
Those are useful signals.
The first step is to identify what is crossing the layer without enough translation.
For example, a page handler may currently call an application service like this:
var result = await _articleService.PublishAsync(
Input,
ct);
If Input is a Razor Page input model, the application layer is receiving web language.
A small improvement is to translate it at the boundary:
var result = await _articleService.PublishAsync(
new PublishArticleRequest(
Input.ArticleId,
CurrentUserId),
ct);
This change may seem modest.
But it strengthens the boundary.
The page still owns model binding.
The application service now receives an application request.
The use case becomes easier to call from tests, APIs, background jobs, or other entry points.
A second step is to replace framework-shaped results with application-shaped results.
For example, an application service may currently return a web result:
public async Task<IActionResult> PublishAsync(
PublishArticleRequest request,
CancellationToken ct)
{
// Publish article.
}
That ties the use case to the web layer.
A stronger boundary returns an application result:
public async Task<PublishArticleResult> PublishAsync(
PublishArticleRequest request,
CancellationToken ct)
{
// Publish article.
}
The web layer can then translate the result:
return result.Status switch
{
PublishArticleStatus.Succeeded => RedirectToPage("/Admin/Articles"),
PublishArticleStatus.NotFound => NotFound(),
PublishArticleStatus.NotReady => Page(),
_ => StatusCode(StatusCodes.Status500InternalServerError)
};
The application result can now be reused by different delivery mechanisms without carrying HTTP or page concepts into the application layer.
A third step is to give pass-through methods more meaningful responsibilities.
A method like this may not add much boundary protection:
public Task<Article?> GetArticleAsync(
string articleId,
CancellationToken ct)
{
return _articleRepository.GetByIdAsync(
articleId,
ct);
}
A stronger method names the question and translates the result:
public async Task<ArticleDetailsResult> GetArticleDetailsAsync(
string articleId,
CancellationToken ct)
{
var article = await _articleRepository.GetPublishedArticleAsync(
articleId,
ct);
if (article is null)
{
return ArticleDetailsResult.NotFound();
}
return ArticleDetailsResult.Found(
new ArticleDetails(
article.Title,
article.ContentMarkdown,
article.PublishedAt));
}
The method is still simple.
But it now protects a responsibility.
It asks a meaningful persistence question, handles an expected outcome, and returns a shape that fits the caller’s purpose.
A fourth step is to split models by boundary when they are serving different purposes.
If one model is used as a database entity, application result, API response, and UI view model, it may be convenient, but it is probably carrying too many assumptions.
A first improvement does not need to split everything.
Start where the model is causing friction.
For example, a public page does not need the full persistence entity. It may only need a public read model:
public sealed class PublicArticleDetails
{
public string Title { get; init; } = string.Empty;
public string ContentMarkdown { get; init; } = string.Empty;
public DateTimeOffset PublishedAt { get; init; }
}
The mapping may add a few lines of code.
But it limits what crosses the boundary.
The public page receives the language it needs, not the storage shape that happened to contain the data.
A fifth step is to translate expected technical failures into application outcomes.
For example, a database constraint violation may currently travel upward as an exception. That may be technically accurate, but it may not express the application meaning.
If duplicate article slugs are an expected outcome, the application can expose that result directly:
return CreateArticleResult.Failed(
CreateArticleFailureReason.SlugAlreadyExists);
The technical exception can still be logged.
The database can still enforce the constraint.
But the application boundary now speaks in workflow language.
That makes the behavior easier to test and easier to present through different delivery mechanisms.
A sixth step is to add tests around the boundary being strengthened.
Tests help confirm that the change is structural, not behavioral.
For example, after extracting PublishArticleRequest and PublishArticleResult, a test can call the application service directly:
[Fact]
public async Task PublishAsync_ReturnsNotReady_WhenArticleHasNoContent()
{
var repository = new FakeArticleRepository();
var service = new ArticlePublishingService(repository);
repository.Add(
new Article
{
Id = "article-1",
Title = "Draft",
ContentMarkdown = string.Empty
});
var result = await service.PublishAsync(
new PublishArticleRequest("article-1", "user-1"),
CancellationToken.None);
Assert.Equal(
PublishArticleStatus.NotReady,
result.Status);
}
This test verifies the application behavior without depending on the web layer.
The web layer should still have its own tests where needed.
But those tests can focus on translation: which result becomes a redirect, which result becomes a validation message, and which result becomes a status code.
That separation makes both the code and the tests clearer.
A practical improvement sequence could look like this:
- Choose one feature where a layer feels weak.
- Identify what crosses the layer without translation.
- Introduce an application request or result where needed.
- Rename generic methods into application operations.
- Replace pass-through methods with meaningful questions or outcomes.
- Split models that serve different responsibilities.
- Translate expected technical failures into application results.
- Add tests around the application behavior.
- Keep integration tests for the assembled path.
- Repeat with the next feature.
This sequence is intentionally gradual.
It avoids turning boundary cleanup into a separate architecture project.
Instead, boundaries improve as part of normal development.
That is often the most practical way to strengthen a real system.
A useful question after each change is:
What can no longer leak past this boundary?
If the answer is clearer than before, the boundary has improved.
Maybe web input no longer enters the application layer.
Maybe persistence entities no longer reach the UI.
Maybe database exceptions no longer define expected workflow outcomes.
Maybe tests no longer need a full framework setup to verify an application rule.
Maybe a method name now describes a use case instead of a technical action.
Each of those improvements matters.
They make the existing layers more meaningful.
The goal is not to make every boundary heavy.
Some features are simple. Some models can be shared safely. Some methods can remain small. Some technical details are harmless when the scope is limited.
The goal is to make each boundary strong enough for the responsibility it protects.
A good boundary should translate where translation matters.
It should name the concepts it owns.
It should keep framework and persistence details from traveling farther than necessary.
It should make tests easier to write at the right level.
It should make future change more local.
Layers give the system structure.
Boundaries give that structure purpose.
Strengthening boundaries gradually is the process of making the structure earn its place.
11. Conclusion
Layers can make a system easier to organize.
They give code a place to live. They help separate web concerns from persistence concerns, infrastructure from application services, UI code from storage code, and tests from production code. They can make dependencies easier to see and solutions easier to navigate.
But layers do not automatically create boundaries.
A boundary exists only when a layer protects a responsibility.
That protection usually appears through translation, naming, ownership, and clear vocabulary.
A web request becomes an application request.
A persistence entity becomes a read model or application result.
A technical exception becomes an expected application outcome.
A generic persistence query becomes a named application question.
A framework result stays near the framework instead of becoming the language of the use case.
Without that translation, layers can become corridors.
Data enters one side, passes through several projects or classes, and leaves the other side with the same assumptions still attached. The solution may look layered, but the concepts are not really contained.
That is why a system can have an application layer without application language.
It can have an infrastructure layer while infrastructure details still shape the core.
It can have repositories while persistence queries still define workflows.
It can have view models while UI assumptions still influence application services.
It can have tests while simple rules still require a full framework setup.
The structure is present.
The boundary is not.
That distinction matters because weak boundaries create hidden costs.
A database change affects a page.
A web input model becomes part of a use case.
A persistence entity becomes a public response.
A technical exception becomes the only way an expected outcome is represented.
A test for application behavior requires a web host, a database, or a UI framework.
A method named SaveAsync hides whether the operation is really publishing, archiving, editing, or scheduling.
Each of these issues makes change harder.
Developers must understand more of the system than the change should require. They must know which assumptions have traveled across which layers. They must inspect framework details to understand application behavior. They must follow data through pass-through services, generic models, and technical result types before they can see what the application means.
A strong boundary reduces that cost.
It keeps responsibilities closer to where they belong.
It lets the web layer handle web concerns.
It lets persistence handle storage concerns.
It lets infrastructure adapt technical mechanisms.
It lets application services name and coordinate use cases.
It lets domain concepts or policies express rules where appropriate.
It lets tests verify behavior at the right level.
A strong boundary also makes leaks easier to notice.
If an application service accepts a Razor Page input model, the mismatch is visible.
If a use case returns IActionResult, the result type feels out of place.
If a public read model contains persistence-only fields, the name raises questions.
If a repository exposes IQueryable, the caller is clearly being asked to understand persistence.
If a method is named after a use case, framework details inside it become easier to challenge.
That is one of the practical benefits of boundaries.
They make responsibility visible enough to discuss.
The goal is not to add ceremony everywhere.
Not every feature needs many models, many layers, or complex abstractions. Simple paths can remain simple. Some data can pass directly when the responsibility is obvious and the cost of translation would be higher than the benefit.
But important decisions need a home.
Expected outcomes need application language.
Framework and persistence details need limits.
Models need to belong to a purpose.
Tests need entry points that match the behavior they verify.
A useful question is:
What does this layer prevent from leaking further into the system?
If the answer is unclear, the layer may only be a location.
Another useful question is:
What translation happens at this boundary?
If almost nothing is translated, the layer may not be protecting much.
These questions help keep the architecture grounded.
They shift the discussion away from whether the solution has the right number of projects and toward whether the responsibilities are actually separated.
A good layered architecture is not defined only by where code is placed.
It is defined by what each layer refuses to let pass unchanged.
That refusal does not need to be rigid.
It only needs to be deliberate.
A boundary should protect the language, decisions, and assumptions that belong on each side.5
When it does, layers become more than structure.
They become part of how the system stays understandable as it changes.
Need a second opinion on a .NET codebase?
I offer practical architecture and maintainability advisory for .NET teams working on long-lived business applications.
Want occasional updates?
Subscribe to receive occasional practical notes on .NET architecture, maintainable software, project progress, and new articles.
