Software Engineering
How to Structure a Maintainable ASP.NET Core Application
A maintainable solution makes business change local and dependencies visible. Folder names and layer diagrams matter only when they help a developer trace, test and safely alter a real workflow.

Structure for the next change, not the first commit
Small ASP.NET Core applications can begin with endpoints, Entity Framework and a few services in one project. That is not architectural failure. Problems start when every feature crosses the same large service classes, controllers contain transaction rules, and understanding one user action requires visiting folders named Controllers, Services, Repositories, Managers and Helpers.
A useful structure reduces the number of places affected by a business change. It keeps framework concerns at the edge, puts authoritative rules near the language of the domain, and makes external dependencies visible. It does not maximise project count.
Begin from workflows: create order, approve invoice, invite user. The module or feature that owns a workflow should contain enough of its command, validation, application logic and tests to be understood without touring the whole solution.
Choose complexity by pressure
| Application pressure | Sensible starting structure |
|---|---|
| Small CRUD service, one team | One web project with feature folders and focused tests |
| Business rules and several adapters | Web host plus application/domain and infrastructure boundaries |
| Large modular product | Modules with explicit contracts and architecture tests |
| Independent scaling or ownership | Consider service boundaries only after operational costs are justified |
Projects are compile-time boundaries; folders are navigation aids. Add a project when dependency direction or deployment/testing isolation needs enforcement. A separate project that every other project references in both directions is ceremony, not separation.

Keep the web project as a composition boundary
The host owns ASP.NET Core concerns: routing, authentication schemes, middleware, serialisation, dependency registration, health endpoints and configuration startup. Endpoints translate HTTP into application requests and translate outcomes back into HTTP. They should not become the only place business invariants exist.
Thin endpoints do not mean no endpoint logic. Selecting a status code, reading an ETag, binding a route and enforcing an HTTP-specific policy belong there. Moving these into a generic service merely hides the protocol.
Keep Program.cs readable by grouping registration into cohesive extensions or installers, but avoid one magical AddApplication() method whose internal order nobody can trace. The composition root should reveal major dependencies and startup choices.
Application services coordinate; they do not own every rule
An application handler coordinates a use case: load current state, authorise, invoke domain behaviour, persist, publish an event and return an outcome. It is the right place for transaction boundaries and dependency orchestration. It should speak in business operations rather than offer generic ProcessAsync methods.
When every rule lives in handlers, the same invariant is repeated across HTTP, jobs and message consumers. Put rules that must always hold with the state transition or domain service that owns them. Keep rules specific to one command-such as required input combinations-in request validation.
A mediator library can dispatch commands and behaviours, but it does not create architecture. If every endpoint has one handler and the indirection improves pipeline consistency, fine. If developers must jump through five interfaces to call a method in the same process, remove it.
The domain model can be modest and still authoritative
Not every application needs aggregates, value objects and domain events. A transaction script can be clear for simple data management. Introduce richer domain objects where important state transitions, calculations or terminology recur and deserve one authoritative implementation.
Do not let persistence entities become public API contracts. They expose fields clients should not set, couple schema migration to contract evolution and encourage business rules to be bypassed through property assignment. Map at boundaries where the models genuinely differ; do not create duplicate types with no semantic distinction.
Domain code should not depend on HttpContext, EF tracking behaviour, message brokers or configuration providers. Pass the identity, time, policy result or value it needs. That makes rules usable from more than one entry point and cheap to test.
Infrastructure adapters contain volatility
Entity Framework, object storage, email, payment gateways and external APIs belong behind boundaries named for the capability the application needs. IInvoiceRepository may be useful if it protects aggregate loading and concurrency. A generic repository wrapping every DbSet often removes useful EF capabilities while adding no business meaning.
Interfaces are valuable at volatility or ownership boundaries, not automatically for every class. An interface with one implementation is justified when it expresses an application port or enables a clear test seam. It is not justified merely because a dependency-injection container can resolve it.
Translate provider errors in the adapter. The application should understand “payment outcome unknown” or “reference already exists,” not vendor exception codes. Preserve enough internal context for diagnostics without leaking it into the domain contract.
Organise by feature when change is feature-shaped
Technical-layer folders put all controllers together and all validators together, even though those files rarely change together across features. Feature folders keep an order endpoint, command, validator and mapper close. Shared policies and infrastructure remain central only when they are genuinely shared.
A vertical slice is not permission to duplicate authentication, error contracts and persistence policy in every folder. Centralise cross-cutting mechanisms with narrow, visible hooks. Avoid a “Common” project that becomes a dependency landfill; shared code needs an owner and a reason it cannot belong to one module.
For larger systems, module boundaries should prevent direct access to another module's tables and internals. Communicate through explicit contracts or application ports. A modular monolith with enforced ownership is often easier to operate than premature services while preserving a future separation seam.
Transactions define real boundaries
Project references do not create consistency. Decide where one business operation commits and what happens to external side effects. A database update and message publication require an outbox or equivalent reliable pattern if losing the message is unacceptable. A remote call cannot join the local transaction merely because both occur in one handler.
Keep transaction scope short and avoid network calls while holding database locks when possible. Model uncertain external outcomes and reconciliation. Architecture diagrams that show clean layers but ignore commit order do not describe the system's most important boundary.
Cross-cutting behaviour belongs at the narrowest correct layer
Authentication is a web boundary; resource authorisation may need application data. Request validation belongs before expensive work; domain invariants belong with state changes. Retries belong around specific transient dependency operations, not the whole business command. Logging should describe the workflow without every method announcing entry and exit.
Pipeline behaviours and middleware are powerful precisely because they apply broadly. Every global behaviour needs exclusions, ordering and failure semantics understood. Hidden transactions or retries around arbitrary handlers create surprising side effects.
Test architecture through meaningful boundaries
Unit-test pure calculations and state transitions. Integration-test EF mappings, constraints, transactions, serialisation and infrastructure adapters against realistic dependencies. Host the application for HTTP tests that prove authentication, validation and Problem Details wiring. A small number of end-to-end journeys protects deployment integration.
Do not mock every class. Tests coupled to call order freeze implementation and encourage interfaces with no architectural value. Fake ports the application genuinely owns, and use real infrastructure where the risk is provider behaviour.
Architecture tests can enforce that domain projects do not reference infrastructure and modules do not access each other's internals. They are useful only when the rule has a reason; a dependency diagram preserved after its assumptions disappear becomes another obstacle.
A pragmatic solution shape
src/
App.Web/ HTTP, middleware, composition
App.Application/ Use cases and ports
App.Domain/ Important rules and state transitions
App.Infrastructure/ EF Core and external adapters
tests/
App.Domain.Tests/
App.IntegrationTests/
App.ApiTests/For a small application, these can be folders in fewer projects. For a modular product, repeat application/domain/infrastructure concepts inside owned modules rather than creating one enormous layer for the whole company. Let dependency pressure earn each boundary.
Maintainability checklist
- A workflow can be traced without visiting unrelated features.
- HTTP, domain and persistence models differ only where semantics differ.
- Important invariants survive non-HTTP entry points.
- External providers are translated at explicit adapters.
- Transactions and uncertain side effects have designed outcomes.
- Dependency direction is enforced where it matters.
- Cross-cutting behaviour has visible order and scope.
- Tests use real boundaries instead of mocking the implementation graph.
- Shared projects have ownership and remain small.
- New projects and abstractions have a named change they make safer.
Related C3 Software guides
Frequently asked questions
How many projects should an ASP.NET Core solution have?
Only enough to enforce useful dependency, testing or deployment boundaries. Small applications can remain in one project with clear feature folders.
Should every service have an interface?
No. Add interfaces at application ports, ownership boundaries or meaningful substitution seams-not as dependency-injection ceremony.
Is clean architecture required?
No. Its dependency principles are useful, but adopt only the layers and abstractions justified by the application's change and risk profile.
Should code be organised by feature or technical layer?
Feature organisation usually matches business change better. Keep genuinely shared mechanisms central and prevent feature folders from duplicating cross-cutting policy.
Structure software for safe change
C3 Software Limited has built and supported business software in the UK since 2001. If ordinary changes now cross too much of the solution, talk to C3 Software about an architecture review.