Keep Framework Code at the Edges
Practice · 2026-10-09
A practical look at why framework-specific code should stay near the technical edges of an application, where it can translate requests, UI events, persistence details, configuration, and other mechanisms into application-level intent without owning the core decisions.
Keep Framework Code at the Edges
1. Introduction: Frameworks Are Useful, but They Are Not the Application
Frameworks are valuable because they remove a great deal of repetitive technical work.
They help applications receive requests, render screens, bind input, validate models, persist data, manage dependencies, read configuration, handle middleware, log behavior, and communicate with external systems.
That support matters.
Most modern applications would be slower to build, harder to maintain, and less consistent without good framework support.
The problem begins when framework concepts move from supporting the application to shaping the application.
A controller action starts to contain workflow decisions. A page handler starts to own business behavior. A database mapping starts to influence what the domain object is allowed to mean. A UI component starts to define which actions are valid. A middleware component starts to decide expected application outcomes.
Each individual decision may seem reasonable.
The framework is already there. The code is close to the request, the screen, the database, or the configuration. Adding one more condition in that place often feels like the fastest and simplest solution.
Over time, however, the application can become difficult to separate from the framework around it.
The system may still work. It may still follow many framework conventions. It may still look organized from the outside.
But the application’s own language becomes harder to see.
Use cases are described in terms of HTTP requests, page handlers, entity mappings, UI events, model binding, middleware, or framework-specific lifecycle methods instead of the work the system is meant to perform.
That is not a problem because framework code exists.
Framework code has to exist somewhere.
A web application needs routing. A desktop application needs UI events. A persistence layer needs mapping. An API needs serialization. A hosted service needs configuration and lifetime management.
The issue is not the presence of framework code.
The issue is ownership.
Frameworks should support the application. They should not become the application.
When framework code stays near the edges, it can translate between technical mechanisms and application intent.
A web request can become an application command. A UI event can become a use case call. A database row can become application data. A configuration value can become an option the application can reason about. A technical failure can become an application-level result.
The core of the application can then remain focused on intent, workflow, rules, decisions, and outcomes.
That separation does not require avoiding frameworks.
It requires being deliberate about where framework-specific concepts are allowed to live.
A framework is a tool for delivering the application.
It should not be the place where the application’s meaning disappears.
2. What “the edges” Means
Keeping framework code at the edges does not mean keeping frameworks out of the application.
That would be unrealistic.
A web application needs a web framework. A desktop application needs a UI framework. A persistence layer often needs a database framework. A hosted service needs configuration, logging, dependency injection, and lifetime management.
Those things belong in the system.
The important question is how far inward they are allowed to reach.
The edge of an application is the place where the application meets something outside its own core language.
That may be a browser request, an API call, a database, a file system, a message queue, an email provider, a desktop window, a command line, or a background host.
At those edges, framework code has a useful job.
It receives technical input, handles technical mechanics, and translates between external forms and application-level intent.
For example, a Razor Page handler may receive form input. That is a web concern.
But the application use case should not need to know that the input came from a Razor Page. It should receive something closer to application language, such as a command, request, or operation-specific model.
A controller may know about routes, HTTP status codes, model binding, authorization attributes, and redirects.
But the application service should not need to know about IActionResult, ModelState, route values, or the current HTTP request.
A repository implementation may know about EF Core, SQL queries, tracking behavior, migrations, and database exceptions.
But the application layer should not need to know whether the data came from EF Core, Dapper, JSON files, or an external API.
A UI event handler may know which button was clicked.
But the application use case should not be expressed as a button-click operation. It should be expressed as something the application actually does.
That is what “the edges” means.
The edge is not necessarily a separate project, folder, or assembly.
Those structures can help, but they are not the point by themselves.
The real edge is a responsibility boundary.1
On one side, technical mechanisms are allowed to exist in their own language.
On the other side, the application should speak in terms of workflows, rules, decisions, results, and outcomes.
The edge is where translation happens.
Framework code belongs near the technical edges of the system, where it can translate external mechanisms into application-level intent without owning the core decisions.
For example:
public async Task<IActionResult> OnPostPublishAsync(
string articleId,
CancellationToken ct)
{
var result = await _articleService.PublishArticleAsync(
new PublishArticleRequest(articleId),
ct);
if (!result.Succeeded)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage);
return Page();
}
return RedirectToPage("/Admin/Articles");
}
In this example, the page handler still uses framework concepts. It returns IActionResult. It updates ModelState. It redirects to another page.
That is acceptable because the handler is at the web edge.
The important part is that those framework concepts do not become the use case itself. The application service receives a request that expresses application intent: publish an article.
The handler translates between the web framework and the application.
That is a healthy edge.
Keeping framework code at the edges is therefore not about purity.
It is about keeping technical delivery mechanisms from becoming the language of the application’s core decisions.
Frameworks should be allowed to do their work.
But the center of the system should still be understandable without starting from the framework.
3. How Framework Code Moves Inward
Framework code rarely moves inward all at once.
It usually happens through small, reasonable decisions.
A controller action needs one extra condition before calling the application service. A page handler needs to check whether an operation is allowed. A repository method exposes a query because the caller needs one more filter. A UI event handler performs a little more work because the required state is already available on the screen.
Each change may be easy to justify in isolation.
The code is nearby. The framework already provides the object, hook, attribute, event, or lifecycle method where the change can be made. Adding the condition there feels faster than creating a new application-level concept.
That is how framework code starts to move inward.
The movement often begins when the framework location is treated as the natural owner of a decision simply because that is where the decision first becomes visible.
For example, a web request may reveal that an article cannot be published. Because the request arrives in a page handler, the rule is added there:
public async Task<IActionResult> OnPostPublishAsync(
string articleId,
CancellationToken ct)
{
var article = await _articleRepository.GetByIdAsync(articleId, ct);
if (article is null)
{
return NotFound();
}
if (string.IsNullOrWhiteSpace(article.Title) ||
string.IsNullOrWhiteSpace(article.ContentMarkdown))
{
ModelState.AddModelError(string.Empty, "The article is not ready to publish.");
return Page();
}
article.IsPublished = true;
article.PublishedUtc = DateTime.UtcNow;
await _articleRepository.SaveAsync(article, ct);
return RedirectToPage("/Admin/Articles");
}
This code may work.
It may even be the fastest way to implement the first version of the feature.
But several responsibilities have now gathered in the web edge. The page handler loads data, evaluates publishing rules, changes article state, sets the publication time, saves the result, decides how failures are shown, and chooses the next page.
The framework method has become more than an entry point.
It has become the owner of the use case.
That is the usual direction of framework leakage. A technical edge first receives the operation, then gradually starts to define the operation.
The same pattern can appear in other places.
A repository exposes persistence mechanics because it gives the caller flexibility. A view model gains workflow rules because the screen needs to react to state. A middleware component starts interpreting expected failures because it already handles technical exceptions. A configuration switch starts deciding application behavior because the value is already available during startup.
Again, the reason is understandable.
The framework provides a convenient place to add code.
The problem is that convenience can quietly become ownership.
A page handler starts as a translator and becomes a use case. A repository implementation starts as a persistence adapter and becomes a query language exposed to the application. A view model starts as presentation state and becomes business state. A framework attribute starts as metadata and becomes the only expression of a rule.
At that point, the application becomes harder to separate from the framework.
Testing without the framework becomes harder. Reusing the behavior from another entry point becomes harder. Changing the framework becomes harder. Explaining the use case without mentioning framework mechanics becomes harder.
This is why framework leakage should be noticed early.
It is much easier to keep framework code near the edge than to pull it back after application behavior has started depending on it.
A useful question is:
If this framework disappeared, would the application still have a clear way to express this decision?
If the answer is no, the framework may already be too close to the center.
4. When Framework Language Replaces Application Language
One of the clearest signs that framework code has moved too far inward is that the application starts speaking in framework language.
This can happen quietly.
At first, framework terms appear only where they belong. A web layer talks about requests, responses, routes, model binding, page handlers, controllers, and status codes. A persistence layer talks about entities, mappings, queries, tracking, migrations, and database exceptions. A UI layer talks about controls, events, bindings, commands, and component state.
Those words are not wrong.
They are useful in the parts of the system that deal with those mechanisms.
The problem begins when those words become the main language of the application itself.
A use case is no longer described as publishing an article, restoring a license, sending feedback, or confirming a download. It is described as handling a postback, updating a tracked entity, returning an action result, setting a component flag, or satisfying a model binder.
The technical mechanism has started to hide the application intent.
For example, this kind of method name says more about the web framework than about the use case:
public async Task<IActionResult> OnPostAsync(CancellationToken ct)
That may be a perfectly reasonable Razor Page handler name.
At the web edge, the framework controls part of the shape.
But if the actual application behavior only exists inside that method, the use case has no name of its own.
A clearer design gives the workflow a name outside the framework entry point:
var result = await _articleService.PublishArticleAsync(
new PublishArticleRequest(articleId),
ct);
Now the page handler can still be a page handler, but the application operation is expressed in application language.
The difference is small, but important.
Framework language often describes how the operation is delivered.
Application language should describe what the operation means.
The same problem appears with persistence.
If application code talks primarily in terms of DbSet, IQueryable, Include, tracking behavior, or database exception types, it may be depending too directly on the persistence mechanism. Those concepts matter inside the repository or infrastructure adapter, but they are usually not the language of the use case.
An application service should not normally ask:
var articles = _dbContext.Articles
.Where(article => article.IsPublished)
.OrderByDescending(article => article.PublishedUtc);
It should usually ask for something closer to its intent:
var articles = await _articleRepository.GetPublishedArticlesAsync(ct);
The second version does not remove the query.
It gives the query a clearer home and a clearer name.
It says what the application needs, not how the database framework happens to produce it.
UI frameworks can also replace application language. A button click, selected row, checked box, bound property, or component event may be the way a user starts an operation. But the operation itself should not be named after the control.
The user does not really want to execute SaveButton_Click.
The user wants to save an article, publish a draft, send feedback, restore a license, or confirm an email address.
That distinction matters because application language travels better than framework language.2
A use case named PublishArticleAsync can be called from a Razor Page, an API endpoint, a background job, a test, or a future UI. A use case hidden inside OnPostPublishAsync is harder to reuse without carrying the web framework with it.
This is not only about naming.
Language shapes where developers put new behavior.
If the system mostly speaks in framework concepts, new behavior will often be added to framework locations. More decisions go into controllers, page models, entity mappings, UI components, middleware, or configuration hooks.
If the system speaks in application concepts, new behavior is more likely to be placed near the workflow, rule, policy, repository, or result it actually belongs to.
That is why language is an architectural concern.
Good names do not guarantee good boundaries, but poor language often reveals unclear boundaries.
A useful question is:
Can I explain this part of the system without mentioning the framework first?
If the answer is yes, the application language is probably still visible.
If the answer is no, the framework may have become the main way the application understands itself.
Frameworks should keep their own vocabulary at the edge.
The center of the application should speak in terms of the work the system exists to perform.
5. Controllers, Pages, and Endpoints Should Translate
Controllers, Razor Pages, endpoints, and similar entry points sit at an important boundary.
They are often the first application code to see a request from the outside world. They receive route values, query strings, form data, headers, cookies, uploaded files, authentication state, and cancellation tokens. They also decide how the result is represented back to the caller.
That makes them important.
But it does not mean they should own the application behavior.
A web entry point should normally translate between the web framework and the application. It should accept framework-shaped input, turn it into application-shaped intent, call the appropriate use case, and translate the result back into a web response.
That is already a meaningful responsibility.
For example, a page handler may receive a form post and call an application service:
public async Task<IActionResult> OnPostPublishAsync(
string articleId,
CancellationToken ct)
{
var result = await _articleService.PublishArticleAsync(
new PublishArticleRequest(articleId),
ct);
if (!result.Succeeded)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage);
return Page();
}
return RedirectToPage("/Admin/Articles");
}
This handler still uses framework concepts.
It returns IActionResult. It uses ModelState. It redirects to a Razor Page. Those are web concerns, and this code is at the web edge.
But the workflow itself has been given to the application layer.
The handler does not decide what makes an article publishable. It does not directly change article state. It does not save the article itself. It does not translate persistence failures into workflow meaning.
It receives a web request and turns it into a publish operation.
That is the boundary doing its job.
The opposite design is easy to create:
public async Task<IActionResult> OnPostPublishAsync(
string articleId,
CancellationToken ct)
{
var article = await _articleRepository.GetByIdAsync(articleId, ct);
if (article is null)
{
return NotFound();
}
if (!article.CanPublish())
{
ModelState.AddModelError(string.Empty, "The article is not ready to publish.");
return Page();
}
article.Publish(_clock.UtcNow);
await _articleRepository.SaveAsync(article, ct);
return RedirectToPage("/Admin/Articles");
}
This version may still look tidy.
It may even be acceptable in a very small application.
But the page handler is now more than a translator. It knows how to load the article, how to evaluate the publishing decision, how to change the article, how to save it, and how to present the result.
The web edge has started to own the use case.
The problem becomes more visible when the same operation is needed somewhere else. If publishing can also happen from an API endpoint, an admin tool, a scheduled job, or an import process, the behavior either has to be duplicated or extracted later.
A better design gives the use case a home before it becomes scattered.
The entry point can remain thin without becoming meaningless. It still has useful work to do:
- Accept the incoming request.
- Let the framework bind route, query, or form values.
- Create an application-level command or request.
- Call the application use case.
- Translate success into a redirect, page result, status code, or response body.
- Translate expected failure into user-facing or caller-facing feedback.
- Leave application decisions to the application layer.
This does not mean every controller action or page handler must contain only one line.
Some web-specific work belongs there. Authorization attributes, anti-forgery handling, model binding, response formatting, redirects, status codes, and page-specific error display are part of the web boundary.
The important distinction is whether the code is translating the request or deciding the workflow.
A useful question is:
If the same operation had to be called from a different entry point, would the behavior still have one clear home?
If the answer is yes, the controller, page, or endpoint is probably acting as a boundary.
If the answer is no, the web framework may already own too much.
Controllers, pages, and endpoints should not be empty for the sake of being empty.
They should be focused.
Their role is to connect the outside world to the application without allowing the outside world to define the application’s behavior.
6. Warning Signs That Framework Code Has Moved Too Far Inward
Framework code does not become a problem simply because it exists in the system.
A web application should contain web framework code. A desktop application should contain UI framework code. A persistence layer should contain persistence framework code. Configuration, hosting, dependency injection, routing, binding, logging, and middleware all need to live somewhere.
The warning sign is not that the framework is visible.
The warning sign is that the application cannot easily be understood without thinking primarily in framework terms.
One sign is that use cases exist only as controller actions, page handlers, endpoint methods, component events, or UI event handlers.
If publishing an article, submitting feedback, restoring a license, or confirming a download has no name outside the framework entry point, the framework may be owning more than delivery. It may be owning the workflow itself.
Another sign is that application services return framework-specific types.
For example, if an application service returns IActionResult, depends on ModelState, reads directly from HttpContext, or builds UI-specific responses, then web concerns have moved past the web edge.
That makes the service harder to reuse from another entry point and harder to test without the web framework.
A third sign is that persistence details leak into higher layers.
This may appear as DbContext, DbSet, IQueryable, EF Core tracking state, database exceptions, SQL-specific behavior, or provider-specific query rules in application code.
When that happens, the application layer is no longer only asking for application data. It is participating in the persistence mechanism.
A fourth sign is that framework attributes become the only expression of important rules.
Attributes for routing, binding, authorization, validation, serialization, or mapping are useful. But if a business rule exists only as an attribute on a web model, DTO, entity, or persistence type, the rule may not have a clear owner outside the framework.
That can become fragile when the same operation is called from a different entry point.
A fifth sign is that tests need large framework setups to verify small application decisions.
If testing a publishing rule requires starting a web host, configuring routing, setting up model binding, initializing a database framework, or driving a UI component, the rule may be too closely attached to the delivery mechanism.
Some integration tests should use the full framework.
But simple application decisions should not always require it.
A sixth sign is that changing frameworks would mean rewriting application behavior, not only replacing delivery or persistence mechanics.
Changing frameworks is never free. Some disruption is expected.
But if use cases, rules, results, and workflow language are inseparable from the framework, the framework has probably moved too far inward.
A seventh sign is that developers choose placement based on framework convenience rather than responsibility.
The question becomes:
Where is the easiest framework hook for this code?
instead of:
Which part of the application should own this decision?
That is often how leakage continues. The framework provides a convenient extension point, so the next rule is added there, and then the next one.
An eighth sign is that expected application outcomes are expressed mainly as technical framework outcomes.
A database exception, HTTP status code, model binding error, serialization failure, or validation attribute may be useful at the boundary. But expected outcomes should usually have application names.
For example, ArticleNotReadyToPublish, LicenseNotFound, DownloadConfirmationExpired, or FeedbackSubmissionDisabled communicates more application meaning than a generic framework failure.
A ninth sign is that the application becomes difficult to explain without first explaining the framework.
If the first explanation of a feature is about routes, page handlers, entity tracking, component lifecycle, middleware order, or binding behavior, the application language may be buried too deep.
Those details may matter.
But they should usually come after the explanation of the workflow, not before it.
These warning signs do not always mean the design is wrong.
Small systems can tolerate more direct framework use. Some applications are mostly simple CRUD over a database and do not need heavy separation between every layer. Some framework conventions are useful precisely because they reduce unnecessary ceremony.
The issue is not whether framework code appears.
The issue is whether framework code owns decisions that should remain understandable outside the framework.3
A useful checklist is:
- Does the use case have a name outside the controller, page, endpoint, or UI event?
- Can application behavior be tested without starting the delivery framework?
- Do application services return application results rather than framework results?
- Are persistence queries hidden behind meaningful repository methods?
- Are business rules expressed somewhere other than framework attributes?
- Are expected failures represented in application language?
- Is framework setup wiring behavior together rather than defining behavior itself?
- Can another entry point reuse the same use case without copying logic?
- Can a developer explain the workflow before explaining the framework mechanics?
If several answers point in the wrong direction, the framework may have moved too far inward.
That does not mean the system needs a rewrite.
It means the next improvement should probably move one decision closer to its real owner.
7. Moving Framework Code Back Toward the Edge
When framework code has moved too far inward, the best response is usually not a large rewrite.
Framework leakage often happens gradually, so it is usually safer to improve it gradually as well.
The goal is not to remove all framework references from the system. The goal is to move application decisions toward clearer owners and let framework code return to its supporting role.4
A useful first step is to choose one use case that feels too dependent on the framework.
It might be a large controller action, a Razor Page handler that contains most of the workflow, a UI event handler that performs several application decisions, or an application service that returns web-specific types.
Then look for one decision inside it that clearly has a better home.
For example, a page handler may contain this kind of rule:
if (string.IsNullOrWhiteSpace(article.Title) ||
string.IsNullOrWhiteSpace(article.ContentMarkdown))
{
ModelState.AddModelError(string.Empty, "The article is not ready to publish.");
return Page();
}
If this rule describes whether an article can be published, it probably should not belong only to the page handler.
The first improvement could be to move the decision to the article itself, a domain policy, or an application service result:
public bool CanPublish()
{
return !string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(ContentMarkdown);
}
The page handler can then return to translating the result:
var result = await _articleService.PublishArticleAsync(
new PublishArticleRequest(articleId),
ct);
if (!result.Succeeded)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage);
return Page();
}
The framework code still exists.
But it no longer owns the publishing rule.
Another common improvement is to give use cases names outside the framework entry point.
If a workflow exists only inside OnPostAsync, SaveButton_Click, or an endpoint lambda, extract the workflow into an application service, command handler, or use case method with an application-level name.
For example:
await _feedbackService.SubmitFeedbackAsync(request, ct);
or:
await _licenseService.RestoreLicenseAsync(request, ct);
The name does not need to follow a particular pattern.
It should simply describe the work the application is doing.
That makes the behavior easier to find, test, and reuse.
A third improvement is to replace framework-shaped return values with application-shaped results.
An application service that returns IActionResult is tied to the web framework. A service that returns SubmitFeedbackResult, RestoreLicenseResult, or PublishArticleResult can be used from several entry points.
The web layer can then decide how to present the result:
if (result.Status == PublishArticleStatus.NotReady)
{
ModelState.AddModelError(string.Empty, "The article is not ready to publish.");
return Page();
}
The application result expresses what happened.
The page handler decides how that outcome should appear in the web framework.
Persistence leakage can be improved in the same way.
Instead of exposing IQueryable, DbSet, or framework-specific query behavior, create repository methods that describe what the application needs:
Task<IReadOnlyList<ArticleSummary>> GetPublishedArticlesAsync(
CancellationToken ct);
or:
Task<bool> SlugExistsAsync(
string slug,
CancellationToken ct);
This does not make the persistence work disappear. It moves the persistence mechanism behind a boundary that speaks in application language.
The repository implementation can still use EF Core, SQL, JSON files, or another store internally.
But callers no longer build persistence decisions from framework pieces.
Gradual improvement also applies to UI framework code.
A useful first step is often to separate presentation state from application decisions. The UI can still bind to CanPublish, CanSubmit, or CanRestore, but those values should usually be provided by the application or derived from a clearly owned rule.
The UI can still decide how to show the state.
It should not independently define the rule.
For example:
PublishButton.IsEnabled = ViewModel.CanPublish;
is usually better than repeating the publishing rule in the UI control logic.
The same principle applies to configuration and hosting.
If a setting affects application behavior, let the application layer make that behavior explicit. The configuration system can supply the value, but the use case should decide what the value means.
For example, configuration may provide whether feedback submission is enabled. But the feedback service should still return a meaningful result when submission is disabled.
That makes the workflow visible.
A practical sequence for moving framework code back toward the edge could be:
- Pick one use case that is too framework-dependent.
- Identify one application decision hidden in framework code.
- Give that decision an application-level name.
- Move the decision to the layer or object that should own it.
- Let the framework entry point call the use case instead of implementing it.
- Return application-level results instead of framework-specific results.
- Translate those results back into framework responses at the edge.
- Add tests around the application behavior outside the framework.
- Keep separate integration tests for the framework boundary.
- Repeat with the next decision.
This is not a purity exercise.
Some framework code may remain close to the application because the system is small, the behavior is simple, or the cost of abstraction would be higher than the benefit. That can be acceptable.
The important thing is that the placement should be deliberate.
A useful question after each improvement 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.
Keeping framework code at the edges is not about making the framework invisible.
It is about making the application visible again.
8. Conclusion
Frameworks are important.
They help applications handle requests, render screens, persist data, manage configuration, process middleware, connect dependencies, and communicate with the outside world.
Without them, many applications would contain far more repetitive technical code and far less consistency.
The goal is therefore not to avoid frameworks.
The goal is to prevent frameworks from becoming the language of the application itself.
When framework concepts move too far inward, the system becomes harder to understand in application terms. Use cases disappear into controller actions, page handlers, endpoint methods, UI events, persistence queries, configuration hooks, or framework lifecycle methods.
The code may still work.
It may still follow recognizable framework conventions.
But the application’s own meaning becomes harder to see.
That is the real cost.
A system is easier to reason about when its core speaks in terms of the work it exists to perform: publishing an article, submitting feedback, restoring a license, confirming a download, validating a rule, applying a policy, or returning a meaningful result.
Those concepts should remain visible outside the framework mechanisms used to deliver them.
Keeping framework code at the edges helps preserve that visibility.
A web layer can translate requests into use cases. A UI layer can translate user actions into application operations. A persistence adapter can translate application queries into database access. Hosting, configuration, and middleware can support the application without defining its behavior.
The framework still does useful work.
It just does not own the application’s decisions.
This does not require perfect architecture.
It does not mean every framework type must be hidden behind an abstraction. It does not mean small applications need unnecessary ceremony. It does not mean every controller, page, endpoint, or UI component must be reduced to a single line of code.
It means being deliberate about ownership.
When a decision belongs to the application, it should have an application-level name and an application-level home.
When a technical mechanism receives or delivers that decision, it should translate rather than define.
That distinction makes the system easier to test, easier to reuse, easier to explain, and easier to change.
A useful final question is:
Is this code expressing the application, or is it expressing the framework?
Both kinds of code are necessary.
But they should not be confused.
Frameworks should make applications easier to build and run.
They should not be where application meaning disappears.
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.
