When Framework Details Leak Into Application Code
Practice · 2026-10-17
A practical look at how framework details gradually leak into application code through persistence, UI, configuration, middleware, attributes, technical failures, and tests — and how to move those decisions back into application language.
When Framework Details Leak Into Application Code
1. Introduction: Framework Leakage Is Usually Gradual
Framework details rarely leak into application code as a single obvious design mistake.
They usually arrive through small, reasonable decisions.
A controller action needs one additional condition. A repository method exposes a query because the caller needs more flexibility. A view model gains a rule because the screen needs to enable or disable a button. A configuration value starts deciding behavior because it is already available during startup. A middleware component starts translating application failures because it already handles technical exceptions.
Each decision may seem harmless.
The framework is already involved. The code is nearby. The feature needs to be finished. Adding the logic where the framework first exposes the problem often feels like the most direct solution.
That is why framework leakage is easy to miss.
It does not usually look like disorder at first. It often looks like practical progress.
The application still builds. The feature still works. The code may still follow familiar framework conventions. Routes are handled. Pages render. Entities are saved. Validation runs. Middleware executes. Configuration is loaded.
But gradually, the application’s own decisions become harder to see.
A workflow becomes hidden inside a page handler. A business rule exists only as a validation attribute. A persistence query starts defining what the application means by “active”, “visible”, or “ready”. A UI component starts deciding whether an operation is allowed. A technical exception becomes the only way an expected application outcome is represented.
The framework has not merely helped deliver the application.
It has started to describe the application.
That distinction matters.
Frameworks are useful because they handle technical mechanisms. They help an application receive input, render output, persist data, manage dependencies, read configuration, handle middleware, and integrate with external systems.
Those are important responsibilities.
The problem begins when those mechanisms become the place where application meaning lives.
When that happens, developers can no longer understand a feature by looking for the use case, rule, policy, or result that owns it. They must instead search through framework-specific locations: controllers, page models, entity mappings, UI bindings, middleware, configuration setup, attributes, or lifecycle hooks.
That increases cognitive load.
It also makes change harder.
If behavior is scattered through framework mechanisms, the same decision may need to be rediscovered every time the system is extended. Adding a new entry point, testing a rule, replacing a persistence mechanism, changing the UI, or handling a new expected failure becomes more difficult than it should be.
This is the practical side of keeping framework code at the edges.1
The principle is simple: frameworks should translate between technical mechanisms and application intent. They should not own the application’s core decisions.
But in real systems, the boundary is not always obvious.
This article looks at common places where framework details tend to leak into application code: persistence, UI, configuration, hosting, middleware, attributes, technical failures, and tests.
Framework leakage often begins when technical mechanisms start owning rules, workflow decisions, expected outcomes, or application language.
The goal is not to remove frameworks.
The goal is to notice when they have started owning decisions that should remain visible in application language.
2. Persistence Frameworks Should Not Own Workflow Decisions
Persistence frameworks are valuable because they handle difficult technical work.
They map objects to storage. They translate queries. They manage connections, transactions, migrations, change tracking, concurrency, and database-specific behavior. They reduce the amount of repetitive code needed to read and write data.
That support matters.
But persistence is still a mechanism.
It is the way application state is stored and retrieved. It should not become the place where workflow decisions are defined.
One common sign of leakage is when higher layers start depending directly on persistence framework concepts.
For example:
public IQueryable<Article> Articles => _dbContext.Articles;
This may look flexible.
The caller can add filters, sorting, projections, and paging. The repository does not need a new method for every query. The application can shape the data it needs.
But the flexibility comes with a cost.
The caller now needs to understand more than the application question. It also needs to understand how the persistence framework behaves.
It may need to know which expressions can be translated to SQL, when queries execute, whether results are tracked, whether lazy loading is enabled, which includes are required, and which provider-specific limitations apply.
The application layer is no longer only asking for data.
It is participating in the persistence mechanism.
That becomes especially problematic when the query contains application meaning.
For example, the system may need to know which articles are visible to readers, which licenses can be restored, which feedback submissions should be displayed, or which download confirmations are still valid.
Those are not merely database filters.
They are application concepts.
If the meaning is scattered across ad hoc queries, the rule becomes harder to find, harder to test, and easier to apply inconsistently.
A clearer approach is to give the application question a name:
Task<IReadOnlyList<ArticleSummary>> GetPublishedArticlesAsync(CancellationToken ct);
or:
Task<Article?> GetEditableArticleAsync(string articleId, CancellationToken ct);
These methods still use persistence internally.
The difference is that the application layer asks for something in application language instead of assembling the meaning from persistence framework pieces.
The repository implementation may still use EF Core, SQL, JSON files, or another storage mechanism.
But that mechanism remains behind the boundary.
Persistence leakage can also occur when database exceptions become the language of expected application outcomes.
For example, a unique constraint violation may technically be a database exception. But to the application, the outcome may be that a slug, email address, license key, or confirmation token is already in use.
Those outcomes should usually be represented in application language.
Instead of allowing a persistence exception to travel upward as the primary meaning, the persistence boundary or application service can translate it into a result the rest of the application understands:
return CreateArticleResult.Failed(CreateArticleFailureReason.SlugAlreadyExists);
The database remains responsible for enforcing consistency.
But the application should still express what the failure means.
Another form of leakage occurs when persistence models become the only representation of the domain.
In small systems, using the same class for persistence and application behavior may be reasonable. Not every application needs a separate domain model, data model, and transport model.
The problem begins when the object becomes understandable only through the persistence framework.
Attributes, navigation properties, proxy requirements, parameterless constructors, foreign-key conventions, and mapping limitations start shaping what the object can express.
At that point, the persistence model is no longer only stored by the framework.
It is being designed for the framework first and for the application second.
That may still be acceptable in simple CRUD areas.
But it becomes risky when the object owns important behavior, rules, or workflow state.
A useful question is:
Is this code asking an application question, or is it assembling a persistence query?
If the code is only retrieving simple data, direct persistence access may be sufficient.
But if the query expresses business meaning, workflow state, visibility rules, validity, ownership, eligibility, or expected failure behavior, it probably deserves an application-level name and owner.
Persistence frameworks should be powerful at the storage edge.
They should not become the language used to define the application’s workflows.
3. UI Frameworks Should Not Define Application Behavior
UI frameworks are designed to help applications interact with users.
They handle windows, pages, components, controls, events, bindings, commands, validation messages, navigation, layout, and visual state. They make it possible to present information, collect input, respond to interaction, and guide users through workflows.
That work is important.
A good UI framework can make an application easier to build and easier to use.
But the UI is still a delivery mechanism.
It is the place where users interact with the application. It should not become the place where the application’s behavior is defined.
One common form of leakage occurs when UI events start owning workflow decisions.
For example, a button click may begin as a simple way to call an operation:
private async void PublishButton_Click(object sender, EventArgs e)
{
await _articleService.PublishArticleAsync(
new PublishArticleRequest(ViewModel.ArticleId),
CancellationToken.None);
}
That kind of handler is mostly translating a user action into an application operation.
But over time, it is easy for more logic to collect there:
private async void PublishButton_Click(object sender, EventArgs e)
{
var article = await _articleRepository.GetByIdAsync(
ViewModel.ArticleId,
CancellationToken.None);
if (article is null)
{
ShowError("The article was not found.");
return;
}
if (string.IsNullOrWhiteSpace(article.Title) ||
string.IsNullOrWhiteSpace(article.ContentMarkdown))
{
ShowError("The article is not ready to publish.");
return;
}
article.IsPublished = true;
article.PublishedUtc = DateTime.UtcNow;
await _articleRepository.SaveAsync(article, CancellationToken.None);
ShowMessage("The article was published.");
}
This code may work.
But the UI event handler now owns much more than user interaction. It loads data, checks a publishing rule, changes article state, sets the publication time, saves the result, and decides how the outcome is shown.
The button click has become the use case.
That is usually a warning sign.
A user action may start an operation, but the operation itself should be expressed in application language.
The user does not really want to execute PublishButton_Click.
The user wants to publish an article.
That operation should still have a clear name and owner outside the UI framework.
A better UI handler usually stays closer to translation:
private async void PublishButton_Click(object sender, EventArgs e)
{
var result = await _articleService.PublishArticleAsync(
new PublishArticleRequest(ViewModel.ArticleId),
CancellationToken.None);
ShowPublishResult(result);
}
The handler still belongs to the UI.
It knows which control was clicked. It knows how to read the selected article from the view model. It knows how to display the result.
But it does not decide what makes an article publishable.
It does not directly change the article state.
It does not own persistence.
Those decisions remain in the application.
Another common form of leakage occurs through UI state.
For example:
PublishButton.IsEnabled =
!string.IsNullOrWhiteSpace(Article.Title) &&
!string.IsNullOrWhiteSpace(Article.ContentMarkdown) &&
!Article.IsArchived &&
CurrentUser.Role == "Editor";
This may seem harmless because it only controls whether a button is enabled.
But the expression contains several application decisions:
- what makes an article ready,
- whether archived articles can be published,
- and who is allowed to publish.
If those rules exist only in the UI, they become fragile.
Another screen, API endpoint, scheduled job, import process, or command-line tool may need the same operation. If the rule lives only in the UI, it must be duplicated or rediscovered elsewhere.
A safer approach is for the application to expose the decision and for the UI to display it:
PublishButton.IsEnabled = ViewModel.CanPublish;
The UI still controls presentation.
It decides whether a button is enabled, whether a warning is visible, which message is shown, or which screen appears next.
But the meaning of CanPublish should come from an application rule, policy, or use case result.
UI framework leakage can also happen in view models.
A view model often starts as presentation state, but gradually accumulates workflow behavior because it is close to the screen. It already has the selected item, the current input, validation messages, command state, and user choices.
That makes it tempting to place rules there.
Some presentation-specific decisions do belong in the view model.
For example, the view model may decide whether a section is expanded, whether a spinner is visible, or which tab should be selected.
But application decisions should be treated more carefully.
A useful distinction is:
The UI may decide how an application state is presented. It should not be the only place where that state is defined.
This is especially important when the same behavior must be available from more than one entry point.
If publishing, restoring, approving, submitting, cancelling, or confirming can happen outside the current screen, the behavior should not be owned by that screen.
The screen should call the behavior.
It should not define it.
This does not mean UI code should be empty.
A UI must still handle interaction, feedback, selection, navigation, formatting, validation display, loading indicators, and error presentation. Those are real responsibilities.
The problem begins when those responsibilities expand into application ownership.
A useful question is:
If this screen did not exist, would the rule still matter?
If the answer is yes, the rule probably does not belong only in the UI.
UI frameworks should make application behavior visible and usable.
They should not become the place where that behavior is invented.
4. Configuration, Hosting, and Middleware Are Mechanisms
Framework details do not only leak through controllers, repositories, or UI components.2
They can also leak through the surrounding infrastructure that makes the application run.
Configuration, hosting, dependency injection, middleware, filters, startup code, logging, authentication setup, background services, and environment-specific wiring are all important parts of modern applications.
They decide how the application is assembled.
They decide which services are available.
They decide which technical concerns are applied to incoming requests, outgoing responses, background tasks, and external integrations.
That makes them powerful.
But they are still mechanisms.
They should help the application run. They should not become hidden places where application behavior is defined.
One common form of leakage occurs when configuration values start owning decisions directly.
For example, an application may have a setting that controls whether feedback submission is enabled:
services.Configure<FeedbackOptions>(configuration.GetSection("Feedback"));
That is a configuration concern.
The configuration system reads the value and makes it available to the application.
But the meaning of that value should still be expressed in application code.
A feedback use case might decide:
if (!_feedbackOptions.IsEnabled)
{
return SubmitFeedbackResult.Disabled();
}
The configuration value influences behavior, but it does not hide the behavior.
The application still has a visible outcome: feedback submission is disabled.
That is different from scattering the decision through startup code, middleware, page handlers, or UI checks where the workflow becomes harder to follow.
Configuration should provide values.
Application code should decide what those values mean.3
Hosting code can create a similar problem.
Startup and composition code often contain many important registrations:
services.AddScoped<IEmailSender, SmtpEmailSender>();
services.AddScoped<IFeedbackService, FeedbackService>();
services.AddScoped<ILicenseService, LicenseService>();
That is appropriate.
The composition root wires the application together.
But startup code should be careful not to become a workflow engine.
It should not contain rules such as whether a license can be restored, whether feedback should be accepted, whether an article can be published, or whether a confirmation link is expired.
Those decisions may depend on configured services.
They should not be hidden inside service registration logic.
Middleware can also become a tempting place to put application behavior.
Middleware already surrounds requests. It can inspect headers, authentication state, paths, exceptions, responses, and environment settings. It is often a convenient place to handle cross-cutting concerns.
That convenience is useful for technical concerns such as:
- logging,
- correlation IDs,
- exception handling,
- authentication,
- authorization hooks,
- localization,
- security headers,
- compression,
- routing,
- and request diagnostics.
But middleware becomes risky when it starts interpreting normal application outcomes.
For example, unexpected exceptions may belong in exception-handling middleware. A database outage, unhandled null reference, or external provider failure may need centralized logging and a generic error response.
Expected application outcomes are different.
A missing license, expired confirmation link, disabled feedback form, invalid publish request, or article that is not ready to publish should usually be represented by the use case itself.
Those are not merely technical failures.
They are meaningful application results.
If middleware becomes the main place where such outcomes are translated, the workflow becomes harder to understand. Developers may need to know middleware order, exception filters, response rewriting, or custom pipeline behavior before they can understand what the application is doing.
That is usually a warning sign.
Middleware should translate technical boundaries.
It should not become a hidden application service.
The same concern applies to filters, interceptors, and framework lifecycle hooks.
These mechanisms can be useful when they handle technical repetition.
They become dangerous when they hide business decisions.
An authorization filter may check whether the user is authenticated or has a required role. But the application may still need a policy that explains whether this particular user may perform this particular operation on this particular resource.
A database interceptor may add audit fields. But it should not be the only place where important workflow transitions are defined.
A hosted background service may run scheduled work. But the work itself should usually be represented as an application use case, not only as code inside the hosting loop.
For example:
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await _licenseService.ExpireOldTrialLicensesAsync(ct);
await Task.Delay(TimeSpan.FromHours(1), ct);
}
}
In this example, the hosted service owns the schedule and lifetime mechanics.
The application service owns the operation.
That keeps the hosting mechanism from becoming the workflow itself.
A useful distinction is:
Infrastructure code should assemble and support behavior. It should not be the only place where behavior is described.
This does not mean configuration, hosting, or middleware should be passive.
They are necessary parts of the system.
They handle concerns that would otherwise be repeated everywhere.
But they should remain clear about their role.
Configuration supplies values.
Hosting starts and wires the application.
Middleware handles technical request and response concerns.
Filters, interceptors, and lifecycle hooks handle framework-level repetition.
The application should still own its workflows, rules, policies, expected outcomes, and decisions.
A useful question is:
Is this code wiring the application together, or is it deciding what the application means?
If the code is wiring, adapting, configuring, observing, or translating technical concerns, it probably belongs near the infrastructure edge.
If it is deciding whether an operation is allowed, what a state means, how a workflow progresses, or what an expected failure represents, it probably deserves an application-level home.
Configuration, hosting, and middleware should make the application easier to run.
They should not become places where application meaning is hidden.
5. Framework Attributes Should Not Be the Only Place Rules Exist
Framework attributes are useful because they keep common technical declarations close to the code they affect.
They can describe routes, authorization requirements, validation constraints, serialization names, database mappings, UI metadata, dependency behavior, caching rules, and many other framework-specific concerns.
That can make code easier to read.
For example, a web model may declare that a field is required:
public sealed class SubmitFeedbackInputModel
{
[Required]
public string Message { get; set; } = string.Empty;
}
That is a useful boundary-level validation hint.
It helps the web framework bind input, validate form data, and display feedback to the user.
But an attribute is still a framework mechanism.
It should not always be the only place where an important application rule exists.
The problem appears when the application rule and the framework declaration become indistinguishable.
For example, if feedback submission requires a message, that requirement may matter outside one web form. It may also matter when feedback is submitted from an API endpoint, a desktop application, a background import, a test harness, or a future integration.
If the rule exists only as [Required] on a Razor Page input model, the rule belongs to the form more than to the application.
That may be acceptable for purely presentation-level constraints.
It is less safe for rules that define whether the operation itself is valid.
A clearer approach is to let the boundary perform boundary validation, while the use case still protects the application rule:
public SubmitFeedbackResult Submit(SubmitFeedbackRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
{
return SubmitFeedbackResult.Invalid("A feedback message is required.");
}
// Submit the feedback.
}
The web attribute can still exist.
It can provide immediate feedback to the user and integrate with the framework.
But the application no longer depends on that attribute as the only expression of the rule.
The same issue can appear with authorization attributes.
For example:
[Authorize(Roles = "Editor")]
public async Task<IActionResult> OnPostPublishAsync(
string articleId,
CancellationToken ct)
{
// Publish article.
}
This may be a useful web-layer guard.
But it does not necessarily answer the full application question.
Can every editor publish every article?
Can archived articles be published?
Can an editor publish an article owned by another section?
Can publication happen after a scheduled date?
Can a background job publish the same article without a user role?
The attribute answers a framework-level access question.
The application may still need a policy that answers the workflow question:
var result = await _articlePublishingService.PublishAsync(
new PublishArticleRequest(articleId, userId),
ct);
Inside that use case, the application can decide whether the operation is allowed in application terms.
Mapping attributes can create a similar kind of leakage.
A persistence model may contain attributes that describe table names, keys, column lengths, relationships, or concurrency tokens. Those declarations can be appropriate inside infrastructure.
But when important application meaning exists only through persistence mapping, the rule becomes tied to the database framework.
For example, a maximum column length may be a storage constraint.
But if that length is also part of the application’s definition of a valid title, the application should express that rule directly.
Otherwise, the first place the rule may appear is as a database exception.
That is too late and too technical.
Serialization attributes can have the same problem.
An API contract may use attributes to control JSON names, ignored properties, or required fields. Those are useful at the transport boundary.
But the application should not rely on serialization attributes to define what a valid command means.
Transport shape and application validity are related, but they are not the same thing.
A useful distinction is:
Attributes are good at telling a framework how to treat code. They are weaker at explaining why the application behaves a certain way.
That does not mean attributes are bad.
They often provide concise, readable, conventional metadata.
The issue is whether they are the only place where a rule can be found.
A practical test is to ask:
If this operation were called without this framework, would the rule still be enforced?
If the answer is no, the rule may be too dependent on the attribute.
The rule may need an application-level expression as well: a policy, a validator, a use case decision, a domain method, or an application result.
This is especially important for rules involving:
- workflow transitions,
- authorization beyond simple access checks,
- validity of commands,
- ownership,
- eligibility,
- limits,
- scheduling,
- publishing,
- billing,
- licensing,
- and expected failures.
Framework attributes can support those rules.
They should not quietly replace them.
The goal is not to duplicate every attribute in application code.
Simple presentation constraints can remain presentation constraints. Pure mapping metadata can remain mapping metadata. Transport-specific naming can remain transport-specific naming.
The important question is whether the rule matters beyond the framework boundary.
If it does, the application should have a way to express it without depending entirely on an attribute.
Framework attributes are useful declarations.
But important application rules should still have application-level owners.
6. Technical Failures Should Become Application Outcomes
Frameworks and infrastructure code often report problems in technical language.
A database framework throws a constraint exception. An HTTP client returns a timeout. A serializer fails to parse input. A file system call reports that a file does not exist. A message queue rejects a message. An authentication middleware returns a status code.
Those technical signals matter.
They describe what happened at the boundary between the application and a technical mechanism.
But they do not always describe what the outcome means to the application.
That distinction is important.
For example, a database unique constraint violation may technically be a persistence failure. But in application language, it may mean that a slug is already in use, an email address has already been registered, a license key already exists, or a confirmation token has already been consumed.
Those are different application outcomes.
If they are all allowed to travel upward only as database exceptions, the application loses meaning.
The caller must understand the persistence mechanism to understand the workflow result.
A clearer design translates the technical failure into an application result:
catch (DuplicateSlugException)
{
return CreateArticleResult.Failed(CreateArticleFailureReason.SlugAlreadyExists);
}
The database still protects consistency.
The application still expresses the meaning.
The same principle applies to external services.
An email provider may return a timeout, rate-limit response, authentication failure, or temporary service error. Those are technical details. They are useful for logging, diagnostics, retry policies, and operational monitoring.
But the application may need to express a different question:
- Was the feedback accepted?
- Was the confirmation email sent?
- Should the user retry later?
- Should the operation be completed without the email?
- Should the failure block the workflow?
Those are application decisions.
They should not be hidden inside provider-specific exceptions.
For example:
var emailResult = await _emailSender.SendAsync(message, ct);
if (!emailResult.Succeeded)
{
return RegisterUserResult.EmailCouldNotBeSent();
}
This result does not expose SMTP, HTTP, provider-specific error codes, or retry details to every caller.
It says what the failure means to the use case.
Some technical failures are unexpected.
A null reference exception, corrupted configuration file, unavailable database, broken network connection, or unhandled provider failure may represent something the application cannot meaningfully recover from at that point.
Those failures may belong in logging, monitoring, exception middleware, retry policies, or operational alerts.
But expected failures are different.
A missing article, expired confirmation link, duplicate slug, disabled feedback form, invalid license, unavailable download, or rejected state transition is part of the application’s normal behavior.
Those outcomes deserve names.
For example:
public enum RestoreLicenseFailureReason
{
LicenseNotFound,
LicenseExpired,
TooManyDevices,
CustomerEmailMismatch
}
This kind of result gives the application a vocabulary for expected failures.
It also helps callers respond appropriately.
A web page may show a validation message. An API endpoint may return a specific status code. A desktop application may display a dialog. A background job may skip the item and continue. A test may verify the exact failure reason.
Each delivery mechanism can translate the result into its own format.
The use case still owns the meaning.
This is especially useful when the same operation can be reached from more than one entry point.
If the result is expressed only as an HTTP status code, it fits the web boundary but not necessarily a background job, desktop UI, command-line tool, or test.
If the result is expressed only as a database exception, it fits the persistence mechanism but not necessarily the workflow.
Application outcomes travel better than technical failures.
They make it easier to ask:
- What happened?
- Was this expected?
- Can the caller recover?
- Should the user see a message?
- Should the system retry?
- Should the operation continue?
Technical details can still be preserved.
They may be logged. They may be attached to diagnostics. They may influence retry policies. They may be useful to developers and operators.
But they should not always be the primary language of the application.
A useful distinction is:
Technical failures describe what went wrong at a mechanism. Application outcomes describe what that means for the workflow.
That distinction helps keep framework and infrastructure details near the boundary.
It also keeps the application’s behavior understandable without requiring every caller to know which database, provider, serializer, file system, middleware component, or HTTP client produced the original signal.
The goal is not to hide all technical errors.
The goal is to translate expected failures into meaningful application language while preserving technical detail where it is useful.
When technical failures become application outcomes, the system becomes easier to test, easier to explain, and easier to adapt to new entry points.
The framework still reports what happened.
The application decides what it means.
7. Testing Becomes Harder When Behavior Depends on Frameworks
One of the clearest practical costs of framework leakage appears in testing.
Frameworks often provide excellent testing tools.
Web frameworks can test routes, handlers, filters, middleware, and full request pipelines. UI frameworks can be tested through view models, automation tools, or component test frameworks. Persistence frameworks can be tested with test databases, in-memory stores, containers, or integration tests.
Those tests are useful.
The problem is not that framework-based tests exist.
The problem begins when small application decisions can only be tested through a large framework setup.
For example, suppose the rule for publishing an article is hidden inside a Razor Page handler. Testing that rule may require constructing the page model, setting up model binding assumptions, configuring repositories, preparing framework state, calling the handler method, and inspecting an IActionResult.
That may be appropriate for testing the page behavior.
It is unnecessarily heavy for testing whether an article without content can be published.
The same problem appears when rules are hidden in UI events, persistence queries, middleware, filters, attributes, or framework lifecycle hooks.
The test must bring along the mechanism that happens to contain the rule.
That increases friction.
It also makes tests less direct.
A test that should explain an application behavior may instead spend most of its setup explaining framework plumbing.
For example, this kind of test focuses on the application decision:
[Fact]
public async Task PublishArticleAsync_ReturnsFailure_WhenArticleHasNoContent()
{
var repository = new FakeArticleRepository();
var service = new ArticleService(repository, TestClock.Fixed());
var article = new Article
{
Id = "article-1",
Title = "A draft article",
ContentMarkdown = string.Empty
};
repository.Add(article);
var result = await service.PublishArticleAsync(
new PublishArticleRequest(article.Id),
CancellationToken.None);
Assert.False(result.Succeeded);
}
The test is not concerned with Razor Pages, routing, model binding, redirects, or ModelState.
It asks a direct application question:
What happens when publishing is requested for an article without content?
That is different from a web boundary test.
A web boundary test may still be useful, but it should usually verify translation:
- a successful result becomes a redirect,
- a validation failure becomes a model error,
- an unauthorized request is rejected,
- an expected failure is displayed correctly,
- and the correct use case is called.
Those are web concerns.
They should not be the only way to verify the publishing rule itself.
Persistence leakage creates similar testing problems.
If application code depends directly on IQueryable, DbContext, tracking behavior, database exceptions, or provider-specific query translation, tests may need a real or simulated database even for behavior that is mostly application meaning.
Some persistence tests should use the real persistence mechanism.
Repository implementations, mappings, migrations, concurrency behavior, transactions, and provider-specific queries deserve integration tests.
But application tests should not always need those mechanisms simply to verify workflow decisions.
UI leakage has the same effect.
If a rule exists only in a button event, component lifecycle method, binding expression, or view model tied too closely to a specific screen, testing the rule requires testing the UI mechanism.
That makes behavior harder to verify in isolation.
It also makes the rule harder to reuse from another entry point.
A healthy test structure usually mirrors healthy responsibility boundaries.
Domain tests verify domain rules.
Application tests verify use cases, policies, results, and expected outcomes.
Persistence tests verify mappings, queries, storage behavior, and database constraints.
Web tests verify request handling, model binding, authorization behavior, response formatting, and result translation.
UI tests verify interaction, presentation state, navigation, and feedback.
Each test type has value.
The problem appears when one type of test is forced to verify too many unrelated responsibilities because the production code has placed them in the same framework location.
A useful question is:
Can I test this application decision without loading the framework that delivers it?
If the answer is no, the decision may be too closely attached to the mechanism.
That does not mean every behavior must be isolated from every framework.
Some features are naturally framework-heavy. Authentication flows, middleware ordering, rendering behavior, persistence concurrency, file uploads, routing constraints, and UI interaction often need framework-level tests.
But ordinary application decisions should usually have tests that speak in application language.
Those tests are often easier to read because they name the behavior directly.
They are often faster to run because they require less infrastructure.
They are often easier to maintain because they fail when the application behavior changes, not when framework setup changes.
They also make refactoring safer.
When behavior is tested outside the framework, the framework boundary can change without invalidating every application test. A Razor Page can become an API endpoint. A UI can change from desktop to web. A persistence implementation can be replaced. A hosted service can call the same use case on a schedule.
The use case tests still describe the expected behavior.
That is one of the main practical benefits of keeping framework details near the edge.
It does not only make the architecture cleaner.
It makes the behavior easier to verify.
8. Improving Framework Boundaries Gradually
When framework details have leaked into application code, the best response is usually not a large rewrite.
Large rewrites are risky because they try to fix too many boundaries at once.
They often mix several concerns:
- moving rules,
- changing abstractions,
- adjusting tests,
- replacing framework-specific types,
- reorganizing projects,
- and changing how features are called.
That amount of change can make the system temporarily harder to understand, even if the long-term direction is correct.
Framework leakage usually happens gradually.
It is often better to improve it gradually as well.
A useful first step is to choose one specific behavior that feels too dependent on the framework.
It might be a page handler that owns a workflow, a controller action that contains several decisions, a repository method that exposes IQueryable, a UI event that updates application state directly, a middleware component that interprets expected failures, or an application service that returns framework-specific types.
Do not start by trying to fix the entire architecture.
Start by finding one decision that has a clearer home.4
For example, a page handler may contain a publishing rule:
if (string.IsNullOrWhiteSpace(article.Title) ||
string.IsNullOrWhiteSpace(article.ContentMarkdown))
{
ModelState.AddModelError(string.Empty, "The article is not ready to publish.");
return Page();
}
That code may be correct.
But if the rule describes whether an article can be published, the page handler is probably not its best owner.
The rule could move to the article, a policy, a validator, or the application service responsible for publishing:
public bool CanPublish()
{
return !string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(ContentMarkdown);
}
The page handler can then return to translating the result into web feedback.
That is a small improvement.
It does not redesign the whole system.
It moves one decision closer to its owner.
A second useful step is to give hidden workflows application-level names.
If behavior only exists inside OnPostAsync, SaveButton_Click, an endpoint lambda, or a background service loop, extract the workflow into a method that describes the operation itself.
For example:
await _feedbackService.SubmitFeedbackAsync(request, ct);
or:
await _licenseService.RestoreLicenseAsync(request, ct);
The important part is not the exact naming convention.
The important part is that the operation can be found and understood without starting from the framework hook that calls it.
A third step is to replace framework-shaped results with application-shaped results.
If an application service returns IActionResult, it is tied to the web framework.
If it returns SubmitFeedbackResult, RestoreLicenseResult, or PublishArticleResult, the result can be translated by several different entry points.
A Razor Page can turn the result into a validation message or redirect.
An API endpoint can turn it into a status code and response body.
A desktop application can turn it into a dialog.
A background job can log the result and continue.
The application result remains the same.
Only the delivery mechanism changes.
A fourth step is to move persistence questions behind meaningful methods.
Instead of exposing the persistence framework directly, name the application question:
Task<IReadOnlyList<ArticleSummary>> GetPublishedArticlesAsync(
CancellationToken ct);
or:
Task<bool> SlugExistsAsync(string slug, CancellationToken ct);
This does not hide the fact that data must be stored somewhere.
It simply prevents every caller from assembling application meaning out of persistence framework details.
The repository implementation can still use EF Core, SQL, JSON files, or another storage mechanism internally.
The application asks for what it needs.
The persistence edge decides how to retrieve it.
A fifth step is to translate technical failures near the boundary where they occur.
A database constraint violation can become SlugAlreadyExists.
An expired confirmation token can become DownloadConfirmationExpired.
An unavailable email provider can become EmailCouldNotBeSent.
A missing license can become LicenseNotFound.
The technical detail may still be logged.
But the application should receive an outcome it can reason about.
That makes behavior easier to test and easier to present through different entry points.
A sixth step is to add tests around the extracted behavior.
This is important because moving decisions changes structure.
Tests help make sure the behavior itself did not change accidentally.
The goal is not only to increase test coverage.
The goal is to make the new boundary visible.
A test that calls PublishArticleAsync directly documents that publishing is an application operation, not merely a page-handler detail.
A test that verifies SlugAlreadyExists documents that duplicate slugs are expected application outcomes, not only database exceptions.
Those tests make the design harder to accidentally reverse.
A practical improvement sequence could look like this:
- Choose one framework-dependent behavior.
- Identify one decision hidden inside it.
- Give that decision an application-level name.
- Move the decision to its clearer owner.
- Return an application-shaped result.
- Let the framework entry point translate the result.
- Add tests around the application behavior.
- Keep separate integration tests for the framework boundary.
- Repeat with the next decision.
This sequence is intentionally modest.
It avoids treating architecture as a separate project that must be completed before feature work can continue.
Instead, framework boundaries improve as part of normal maintenance and feature development.
That is often how durable architecture improves in real systems.
A useful question after each change is:
Does this code now describe the application more clearly than before?
If the answer is yes, the change is probably moving in the right direction.
If the answer is no, the abstraction may not be helping yet.
The goal is not to create layers for their own sake.
The goal is to make responsibilities easier to see.
Some framework-specific code should remain where it is.
A controller should still know about HTTP.
A Razor Page should still know about page rendering.
A UI component should still know about interaction.
A repository implementation should still know about persistence.
A hosted service should still know about scheduling and lifetime.
The improvement is not removing those mechanisms.
The improvement is preventing them from owning decisions that belong to the application.
Framework leakage often enters through small decisions.
It can usually be corrected the same way.
9. Conclusion
Framework leakage is rarely dramatic at first.
It usually begins with practical decisions made in reasonable places.
A controller action needs one more condition. A repository exposes one more query. A UI event checks one more rule. A validation attribute becomes the only expression of a requirement. A middleware component starts interpreting expected outcomes. A configuration value starts deciding behavior directly.
Each step may seem small.
But over time, those decisions can move application meaning away from the places where developers expect to find it.
The result is not necessarily chaotic code.
The system may still build correctly. It may still follow recognizable framework conventions. It may still pass tests and deliver features.
The problem is more subtle.
The application becomes harder to understand without first understanding the framework mechanisms that happen to contain its behavior.
Persistence queries begin to define workflow state. UI components begin to own rules. Configuration and hosting code begin to hide decisions. Middleware begins to represent expected outcomes. Attributes become the only place important rules exist. Technical exceptions become the language of application failure.
At that point, the framework is no longer only delivering the application.
It is shaping how the application is understood.
That is the cost of leakage.
Keeping framework details near the edges does not mean avoiding frameworks.
It does not mean building unnecessary abstractions around every technical dependency.
It does not mean controllers, pages, repositories, UI components, configuration, middleware, or attributes are bad.
Those mechanisms are useful.
They belong in the system.
The important question is whether they are owning decisions that should remain visible in application language.
A useful boundary allows technical mechanisms to do their work while preserving application meaning.
The persistence layer can store and retrieve data without defining every workflow rule.
The UI can present state and respond to interaction without becoming the only owner of behavior.
Configuration can provide values without hiding what those values mean.
Middleware can handle technical request and response concerns without becoming a hidden application service.
Attributes can support frameworks without replacing application-level rules.
Technical failures can be translated into outcomes the workflow understands.
Tests can verify application decisions without always loading the framework that delivers them.
These distinctions make the system easier to explain, easier to test, easier to reuse, and easier to change.
They also make the code more honest.
A use case should look like a use case.
A rule should have a recognizable owner.
An expected failure should have an application-level name.
A technical adapter should adapt.
A framework edge should translate.
That clarity matters because software is read, changed, tested, extended, and debugged many more times than it is first written.
When application meaning is scattered through framework details, every future change requires more investigation.
When application meaning is visible, developers can reason about the system more directly.
The goal is not architectural purity.
The goal is reducing confusion.
A good question to ask when framework details appear in application code is:
Is this framework helping the application express its intent, or is it starting to replace that intent?
If the framework is helping, the boundary is probably healthy.
If the framework is replacing application language, owning decisions, or hiding expected outcomes, the boundary may need attention.
Frameworks should make applications easier to build and run.
They should not become the place where application behavior is quietly defined.
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.
