This article is for developers who already know ASP.NET Core and need to make architecture decisions that will survive in production. I focus on the decisions the framework cannot make for you: where to draw boundaries, when an abstraction earns its cost, when a repository adds value over EF Core, what should stay inside an HTTP request, how DI lifetimes affect ownership, what is actually worth caching, and which failures your tests and telemetry need to expose. The examples come from ordinary production concerns such as checkout, external APIs, background processing, authorization, and changing business workflows.

1. Start With Boundaries, Not Projects

ASP.NET Core gives you remarkably few opinions about how to structure an application. You can keep almost everything in one web project, build traditional layers, organize by feature, adopt Clean Architecture, or combine several approaches. Most of them work while the system is small.

The differences appear after integrations, background processing, migrations, production incidents, and several years of change.

I do not start by asking how many projects a solution should contain. I start with questions that expose boundaries: Where does this business rule belong? Which code exists only because the application speaks HTTP? Where does data leave our process? What happens when an external dependency is slow? Which work must finish before the response? Which decisions will be expensive to reverse?

ASP.NET Core application boundaries

One operation crosses several kinds of boundaries. Each boundary changes what the application owns and which failures it must handle.

A four-project solution is not automatically better than a one-project solution. Physical separation earns its cost when it reinforces a dependency rule, ownership boundary, or deployment concern. Otherwise, it is ceremony.

What I would choose for three different systems

System

Starting point

Why

Small internal CRUD app

One web project, feature folders

Low ceremony; boundaries can remain logical until complexity appears.

Business application with several integrations

Web edge + application/features + explicit integration boundaries

External contracts and workflows now deserve visible ownership.

Large modular product

Business modules with enforced dependencies

Team ownership and independent change become stronger forces than folder neatness.

The important part is the progression. I do not start a small application with the structure required by a hypothetical future enterprise system. I also do not keep a growing system in one undifferentiated project merely because it started there.

2. Keep the HTTP Layer Thin, But Useful

Controllers and Minimal API handlers are adapters between HTTP and application behavior. They should understand routes, status codes, authentication context, headers, request models, and response contracts. They should not quietly become the place where the whole use case lives.

“Thin” does not mean every endpoint must be one line. Mapping a request, applying HTTP-specific validation and selecting a response legitimately belong there. What I avoid is an endpoint that opens a transaction, performs several EF Core queries, calls two external APIs, implements business rules, and manually constructs five failure responses.

Once that happens, HTTP concerns and application behavior are difficult to test independently and difficult to reuse from a worker or another interface. I want the endpoint to translate HTTP into an application operation, and translate the result back. The use case owns the workflow.

Boundary test: if the same operation had to be triggered tomorrow from a queue consumer instead of HTTP, how much of the controller or endpoint would you need to copy? The answer is a useful signal for how much application behavior has leaked into the transport layer.

3. Don't Create an Interface Just Because a Class Exists

Dependency injection makes interfaces cheap to introduce, which sometimes turns into a design rule: every service gets one. That creates pairs such as IOrderService/OrderService and IOrderRepository/OrderRepository, even when each interface has a single implementation, and protects no meaningful boundary.

An interface is valuable when it isolates something volatile, represents an external capability, has multiple implementations, creates a deliberate seam, or makes a difficult dependency replaceable. “The DI container needs it” is not a reason; ASP.NET Core can register concrete types.

Meaningful abstractions versus mirrored interfaces

A useful abstraction contains decisions or volatility. A forwarding layer that only mirrors another API needs a stronger reason to exist.

This is not an argument against interfaces. IPaymentGateway can be an excellent boundary because the provider's latency, credentials, failure modes, and contract are outside your control. An IOrderRepository that merely mirrors five DbSet operations may buy much less.

4. Use EF Core Directly Until a Repository Solves a Real Problem

EF Core's DbContext is designed around a short unit of work: query entities, track the changes needed for one business operation, call SaveChangesAsync, then dispose the context. In a typical ASP.NET Core application, AddDbContext registers the context as scoped, which makes one context per request a sensible default for many request-oriented operations.

Because DbContext and DbSet<T> already provide query, tracking, and unit-of-work behavior, I do not automatically wrap every entity in a generic repository.

A common repository anti-pattern

This abstraction looks clean at first:

public interface IRepository<T>
{
    Task<T?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
    Task AddAsync(T entity, CancellationToken cancellationToken);
    void Update(T entity);
    void Remove(T entity);
    IQueryable<T> Query();
}

But notice what happened. The repository either exposes IQueryable<T>, which leaks the provider's query model straight through the abstraction, or it must grow methods for every query shape the application needs. Paging, projections, includes, tracking, and transactions still exist. We have added another API without necessarily reducing any persistence knowledge.

Direct DbContext can be the clearer option

public sealed class CancelOrder(AppDbContext db)
{
    public async Task<CancelOrderResult> ExecuteAsync(Guid orderId, CancellationToken cancellationToken)
    {
        var order = await db.Orders.SingleOrDefaultAsync(x => x.Id == orderId, cancellationToken);

        if (order is null)
            return CancelOrderResult.NotFound();

        if (!order.CanBeCancelled())
            return CancelOrderResult.NotAllowed();

        order.Cancel();

        await db.SaveChangesAsync(cancellationToken);

        return CancelOrderResult.Success(order.Id);
    }
}

Here, the persistence work is visible in the application operation. There is one query, one domain decision, and one save. Adding an IOrderRepository that only renames SingleOrDefaultAsync and SaveChangesAsync would not automatically make this use case easier to change.

When a repository earns its place

I would reconsider direct DbContext use when the application has a persistence contract worth naming. Examples include an aggregate whose loading rules must remain consistent, a complex persistence strategy repeated across many use cases, multiple materially different data sources behind a single application capability, or a domain model that benefits from stronger isolation from EF Core.

public interface IOrderStore
{
    Task<Order?> GetForCancellationAsync(Guid orderId, CancellationToken cancellationToken);

    Task SaveAsync(Order order, CancellationToken cancellationToken);
}

This interface is more specific than a generic CRUD repository. It expresses what the application needs from persistence for an order workflow. Its value comes from the contract it protects, not from hiding the name DbContext.

Decision rule: if removing the repository would only expose EF Core method names, the abstraction may not be buying much. If removing it would expose duplicated persistence rules or break a meaningful boundary, it is doing real work.

5. Treat External Systems as Explicit Failure Boundaries

A database under your operational control and a third-party payment API are both I/O, but they do not have the same failure model. External calls cross a boundary where latency, throttling, authentication, contract changes, and partial outages are outside the process.

I prefer to make that boundary obvious. A typed or named HttpClient can own HTTP configuration. A higher-level gateway can translate the provider's transport contract into application-level outcomes. Timeouts and cancellation should be deliberate. Retries require more thought than “retry three times”: the operation must be safe to repeat, and retrying into an overloaded dependency can worsen an incident.

External API as an explicit failure boundary

The gateway is valuable when it translates transport behavior into explicit application outcomes, not merely because it wraps HttpClient.

The application should know what failure means. Is payment unavailability a rejected order, a pending order, a retryable operation, or a reason to queue work? That is an application decision, not something an HTTP library can infer.

Integration review question: show me the timeout, cancellation path, retry/idempotency decision, failure mapping, and telemetry for this dependency. If those decisions are scattered across callers, the boundary is probably too weak.

6. Keep Expensive I/O Visible

Hidden I/O is one of the easiest ways to make otherwise clean code difficult to reason about. Consider a checkout operation that looks harmless at the call site:

var customer = await customerRepository.GetAsync(customerId, cancellationToken);
var status = await customer.GetStatusAsync(cancellationToken);
var price = await pricing.GetPriceAsync(productId, cancellationToken);
var payment = await order.PayAsync(price, cancellationToken);

The code is readable, but the names do not tell you that the last three calls cross process boundaries. In production, the request may actually look like this:

Request timeline showing database, customer API, pricing API and payment API latency

Four individually reasonable calls can turn into a slow request when remote I/O is hidden behind innocent-looking methods.

Assume the database query takes 12 ms, the customer API 180 ms, pricing 240 ms, and payment 900 ms. Before serialization, logging, and network overhead, the request has already spent more than 1.3 seconds waiting on dependencies. If those calls are sequential because each result is genuinely required by the next step, that latency is part of the design. If some are independent, the orchestration should make that visible so you can decide whether safe concurrency is possible.

This is why I am cautious about putting remote calls behind domain-looking methods such as customer.GetStatusAsync(). The method looks like behavior on an in-memory object, but it has concerns about timeouts, cancellation, authentication, and availability. I would rather make the dependency visible at the orchestration boundary:

var customer = await db.Customers
    .SingleAsync(x => x.Id == customerId, cancellationToken);

var customerStatusTask = customerStatusClient.GetAsync(
    customer.ExternalId,
    cancellationToken);

var priceTask = pricingClient.GetPriceAsync(
    productId,
    cancellationToken);

await Task.WhenAll(customerStatusTask, priceTask);

var customerStatus = await customerStatusTask;
var price = await priceTask;

EnsureCustomerCanPurchase(customerStatus);

var payment = await paymentGateway.AuthorizeAsync(
    customer.Id,
    price,
    cancellationToken);

This is not a recommendation to parallelize every call. Parallel I/O can increase pressure on downstream systems, and two operations that appear independent may have consistency requirements. The point is that the orchestration exposes where remote work occurs, making latency and failure part of the design rather than a surprise found in a trace.

The same principle applies to database access. A method named GetOrders() might return an already materialized list, execute one query, or trigger an N+1 sequence. Make expensive boundaries sufficiently visible for a reviewer to estimate the request shape without stepping through every implementation.

Decision rule: abstraction may hide protocol details, but it should not hide the fact that an operation is remote, potentially slow, cancellable, and fallible.

7. Decide What Must Finish Inside the HTTP Request

An HTTP request is a poor home for work that takes minutes, needs durable retries, or must survive an application restart. The client connection, timeout, deployment, and process lifetime become accidental parts of the workflow.

Request path versus durable background work

Returning 202 is meaningful only when you can state what has actually been accepted and what survives a restart of the process.

BackgroundService and IHostedService are useful for work tied to application lifetime, but they are not automatically durable job infrastructure. If the process dies after accepting a request but before an in-memory job finishes, what happens? If the app scales to three instances, which instance owns the job? Where is retry state?

When those answers matter, use a durable queue, scheduler, or worker design with the guarantees you need. The endpoint can validate and persist intent, enqueue work, and return while processing continues independently.

The dangerous middle ground

A common design accepts a request, puts work into an in-memory Channel<T> and returns immediately. That can be perfectly reasonable when losing queued work during a restart is acceptable. It is dangerous for the business to assume that a successful HTTP response means the job is durably accepted. The architecture must make that guarantee explicit.

8. Use DI Lifetimes as Ownership Rules

Singleton, Scoped and Transient are often taught as definitions. In production, the useful question is different: who owns this instance, and how long is that owner allowed to keep it?

A classic failure appears when a long-lived hosted service needs a scoped dependency such as an EF Core DbContext. This is the shape I do not want:

public sealed class InvoiceWorker(AppDbContext dbContext) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await ProcessInvoicesAsync(dbContext, stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
        }
    }
}

BackgroundService implementations registered with AddHostedService are singleton services. AppDbContext is normally scoped. Constructor-injecting the context into the worker therefore creates an invalid lifetime relationship. With scope validation enabled, the container can reject this dependency graph. Even if you manually constructed something equivalent, keeping a single context for a long-running worker would create tracking, growth, stale-state, and lifetime problems.

The scope should belong to one unit of work:

public sealed class InvoiceWorker(IServiceScopeFactory scopeFactory) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await using var scope = scopeFactory.CreateAsyncScope();

            var processor = scope.ServiceProvider
                .GetRequiredService<InvoiceProcessor>();

            await processor.ProcessPendingAsync(stoppingToken);

            await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
        }
    }
}

Now the worker owns the loop, while each iteration owns a scope. Scoped services created inside that scope are disposed when the iteration completes.

A singleton also changes your concurrency assumptions

Suppose a singleton service stores request-specific mutable state:

public sealed class CurrentTenantCache
{
    public string? TenantId { get; set; }
}

Under concurrent requests, one user's tenant can overwrite another's. The problem is not that singletons are bad. The problem is that application-wide ownership was chosen for request-specific state.

I use the same reasoning in the other direction. A stateless formatter with no scoped dependencies may safely be a singleton. A lightweight transient service may be fine even when it is resolved frequently. The lifetime follows state, disposal, concurrency, and dependency ownership, not the class suffix.

9. Keep Configuration at the Edge of Application Behavior

ASP.NET Core configuration is deliberately flexible. JSON files, environment variables, command-line values, secret providers, and custom sources can compose the final configuration. That flexibility should not leak into every class as IConfiguration plus string keys.

I prefer typed options at application or infrastructure boundaries. For settings without which the application cannot operate correctly, I also prefer to fail during startup rather than discover the problem on the first production request.

Validate required configuration when the application starts

public sealed class PaymentsOptions
{
    public const string SectionName = "Payments";

    [Required]
    public required string BaseUrl { get; init; }

    [Range(1, 30)]
    public int TimeoutSeconds { get; init; } = 10;
}
builder.Services
    .AddOptions<PaymentsOptions>()
    .BindConfiguration(PaymentsOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

If BaseUrl is missing or the timeout is outside the accepted range, options validation runs during startup and prevents the application from quietly serving traffic with invalid settings. Without ValidateOnStart, options validation normally occurs when the options instance is first created or accessed.

Do not make business code understand deployment keys

This is the shape I try to avoid:

var timeout = configuration.GetValue<int>("Payments:TimeoutSeconds");

Repeated across application services, the string key becomes part of business code. Instead, bind once and inject the typed contract or, better yet, inject the capability that already uses those settings.

Configuration describes deployment variation. It should not become a general-purpose database for mutable application state. Secret retrieval is also a separate concern from source control: a configuration provider can supply a secret, but that does not make committing credentials to appsettings.json acceptable.

Configuration smell: if business code repeatedly knows keys such as Payments:TimeoutSeconds, deployment vocabulary is leaking into application behavior.

10. Cache a Known Cost, Not a Vague Performance Problem

“This endpoint is slow, add caching” is not yet a design. First, identify which work is expensive.

Imagine GET /products/42 performs two operations: a product query taking about 35 ms and a pricing API call taking about 120 ms. The endpoint receives two million requests per month. Product metadata changes a few times per day, while price can change independently every minute.

Caching the entire response for ten minutes is easy, but it also means a price change may be invisible for ten minutes. Caching only product metadata has a much safer freshness model but leaves the 120 ms pricing dependency untouched. Caching the price for 30 seconds may be acceptable, or completely wrong, depending on the business contract.

Candidate

Cost avoided

Freshness problem

Likely mechanism

Product metadata

35 ms DB query

Changes a few times/day

Memory or distributed data cache

Price

120 ms remote call

Changes independently

Short-lived cache only if contract allows

Full HTTP response

DB + API + mapping

Couples both freshness rules

Output caching if response semantics allow

That is why IMemoryCache, distributed caching and output caching are not interchangeable switches. In-memory state is local to one process. In a three-instance deployment, each instance can hold a different value until expiration. A distributed cache gives instances shared state but adds network I/O and another dependency. Output caching works at the HTTP response layer and can be ideal when the whole response has one coherent caching policy.

Think about the miss path, not only the hit path

If a popular key expires and 500 requests arrive simultaneously, allowing all 500 to rebuild the same expensive value can shift the bottleneck from your app to the database or downstream API. That is a cache stampede. The mitigation depends on the cache mechanism. ASP.NET Core output caching uses resource locking by default for a given response, while a custom data-cache implementation may need its own request coalescing, locking, stale-while-revalidate behavior, jittered expiration, or another strategy.

Also ask what happens after a deployment or cache outage. If the uncached system cannot survive normal traffic long enough for the cache to warm, the cache has stopped being an optimization and become hidden capacity infrastructure.

My sequence: measure the expensive work → define freshness → choose the cache boundary → define invalidation → design the miss path → then choose the ASP.NET Core caching mechanism.

11. Make Failure Observable, Not Merely Logged

Consider a production incident. Normally, 95% of POST /checkout requests finish within about 450 ms. At 14:20, that threshold jumps to 3.2 seconds while the HTTP 5xx error rate barely changes. In monitoring systems this 95th-percentile value is often labeled p95, but the important point is simple: even the slower group of normal requests has become dramatically slower. Users report that checkout "hangs".

A log line saying Checkout completed after 3.1 seconds confirms the symptom but does not explain it. Observability becomes useful when different signals answer different questions.

Distributed trace showing checkout request with SQL and payment API spans and payment latency dominating the request

A trace turns “checkout is slow” into a concrete dependency hypothesis: most of the request time is outside the application process.

Why use a percentile instead of only an average? An average can stay deceptively healthy when a smaller but important group of users experiences very slow requests. Looking at the 95th percentile means asking how slow the request is at the point where 95% of requests are faster, and 5% are slower.

Metrics tell you that checkout latency changed at 14:20 and whether the change affects one instance, region, or endpoint. Traces show that a typical 3.2-second request spent 80 ms in SQL and 2.7 seconds waiting for the payment provider. Structured logs provide event detail for a specific payment attempt, correlation ID, or business outcome.

Those signals should share context. If the trace has one correlation identifier, the payment log another, and the order record no stable reference to either, diagnosis still becomes archaeology.

I instrument architectural boundaries rather than logging every method entry and exit. HTTP requests, database calls, external APIs, queues, and important background operations are useful boundaries because latency and failure can accumulate there. OpenTelemetry and the .NET diagnostics ecosystem make this increasingly practical, but the tool is secondary to deciding which questions the system must answer.

For the checkout example, I would want to answer, without deploying new code:

  • When did latency increase?

  • Is the problem with all requests, or with one dependency path?

  • Which downstream call dominates the trace?

  • Did timeouts or retries increase?

  • Which orders were affected?

  • Did the application fail, or did it successfully return after an unacceptable delay?

Logging records events. Observability is the ability to explain system behavior from the signals you already collect.

12. Test the Boundaries That Can Actually Break

You can have excellent unit tests for an authorization handler and still accidentally expose an endpoint publicly.

Suppose this endpoint is intended for administrators:

app.MapDelete("/users/{id:guid}", DeleteUserAsync);
// Someone forgot: .RequireAuthorization("Administrators")

A unit test for AdministratorRequirementHandler can be perfectly green. The handler itself is correct. The production bug is in composition: the endpoint never references the policy.

An HTTP-level integration test can catch the behavior users actually depend on:

[Fact]
public async Task Delete_user_requires_authentication()
{
    using var client = _factory.CreateClient();

    var response = await client.DeleteAsync($"/users/{Guid.NewGuid()}");

    Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

The same distinction applies elsewhere. A unit test can prove a validator works, while routing never invokes the endpoint you think it does. A serializer test can prove a converter works, while production DI never registers it. A service test can pass with mocks while the real EF Core query cannot be translated.

Failure

Unit test

Integration test

Business rule calculates wrong result

Excellent fit

Usually unnecessary for every permutation

Endpoint missing authorization policy

Can miss it

Excellent fit

Wrong route or model binding

Usually misses composition

Excellent fit

Missing DI registration

Often invisible

Application startup/request exposes it

EF Core query provider behavior

Mock can mislead

Test against realistic provider/database

I do not turn every test into a full-stack test. That would make feedback slower and failures harder to localize. I keep deterministic business logic cheap to test and spend integration-test cost where ASP.NET Core composition, persistence, or infrastructure is itself part of the contract.

Test at the lowest level that can actually fail for the behavior you care about. A lower-level test is not more valuable if it cannot observe the failure mode.

13. Organize for Change, Not for the Architecture Diagram

Layered architecture, vertical slices, Clean Architecture, and modular monoliths are useful vocabularies. None of them should be a scoring system.

Take one ordinary change: add the ability to cancel an order, validate its current state, persist the cancellation, expose an endpoint, and publish an integration event.

In a strongly horizontal solution, the change may require visiting:

Controllers/
    OrdersController.cs
Services/
    OrderService.cs
Repositories/
    OrderRepository.cs
Validators/
    CancelOrderValidator.cs
Dtos/
    CancelOrderRequest.cs
Mappings/
    OrderMappings.cs
Events/
    OrderCancelled.cs

There is nothing inherently wrong with those folders. The problem appears when the architecture forces code that changes together to live far apart, while unrelated order, customer, product, and invoice code is grouped together because the classes share a technical role.

A feature-oriented structure can improve locality:

Orders/
    CancelOrder/
        Endpoint.cs
        Command.cs
        Handler.cs
        Validator.cs
        OrderCancelled.cs

Now a developer investigating “cancel order” has a much smaller search surface. That can reduce navigation cost and merge conflicts in large horizontal files.

But vertical slices do not eliminate architecture

I would not copy a database context, authentication setup, telemetry pipeline, or payment client into every feature directory in the name of independence. Those are shared capabilities with their own ownership. Domain concepts such as Order may also be shared across several order use cases.

Comparison of horizontal technical folders and feature-oriented locality for a Cancel Order change

Feature organization can reduce the number of unrelated locations touched by one change, while shared infrastructure remains shared.

Likewise, a separate Application project can be valuable when it enforces a dependency direction that matters. It is less valuable when it contains only pass-through services and DTOs because a template said every solution needs four projects.

The principle I defend is narrower than “vertical slices are best”: optimize the codebase for the changes your team actually makes, while preserving boundaries whose independence has real value.

If most features require edits across six horizontal buckets, locality is poor. If every feature duplicates infrastructure to remain “pure”, cohesion is poor. Good structure balances both.

14. Design for the Second Year, Not the First Sprint

Most architecture decisions are cheap while the application has no users, little data, and one developer. Their quality becomes visible later.

I want a production ASP.NET Core application to make a few things boring: adding an endpoint, changing a business rule, replacing an integration, diagnosing a slow request, upgrading .NET, running a migration, and onboarding another developer. If each operation crosses unrelated layers and hidden conventions, the architecture is accumulating interest.

That does not justify predicting every future requirement. It justifies documenting decisions with a large blast radius: domain terminology, sources of truth, external contracts, security boundaries, background processing guarantees, deployment topology, and non-obvious architectural choices. A short ADR explaining why a boundary exists is often more useful than a large diagram nobody maintains.

The best architecture is not the one with the most abstractions. It is the one that keeps important costs, dependencies, and decisions visible as the application changes.

My final architecture review

Before calling a structure “production ready”, I want to be able to trace one important request on a whiteboard:

entry point → application operation → database/integrations → asynchronous work → observable outcome.

Then I choose one likely change, replace a payment provider, add a cancellation rule, move work to a queue, and ask which files, contracts, and deployments it touches. Finally, I choose one failure, slow dependency, process restart, missing authorization, and ask whether the system exposes it clearly.

If those three exercises are difficult, another layer or pattern name rarely fixes the problem. The boundaries themselves need work.

Frequently Asked Questions

What is a good architecture for an ASP.NET Core application?

One that keeps HTTP concerns, application behavior, and external dependencies understandable and gives important boundaries explicit ownership. The number of projects or layers should follow complexity rather than a fixed template.

Should every ASP.NET Core service have an interface?

No. Add an interface when it creates a useful seam: multiple implementations, an external dependency, volatility, a deliberate boundary or meaningful test substitution.

Should I use the repository pattern with EF Core?

Not automatically. DbContext and DbSet already provide many repository and unit-of-work capabilities. Add repositories when they express a useful domain or persistence boundary rather than mirroring EF Core operations.

Should business logic be in controllers or Minimal APIs?

Keep HTTP-specific behavior at the endpoint and move substantial workflows and business rules into application or domain code.

When should I use BackgroundService instead of a queue?

Use hosted services for work appropriately tied to process lifetime. If work must survive restarts, retry durably or coordinate across instances, use a durable job or messaging infrastructure.

How many projects should an ASP.NET Core solution have?

There is no universal number. Separate projects or modules when physical separation helps enforce dependency direction, ownership, or deployment concerns.

How should external APIs be handled?

Treat them as explicit failure boundaries. Make timeouts, cancellations, authentication, retry safety, and application-level failure outcomes deliberate.

What should I integration-test?

Focus on important composition and infrastructure boundaries: routing, authorization, serialization, DI configuration, persistence, and critical request-response contracts.

Conclusion

ASP.NET Core already gives us excellent infrastructure. The architectural work is deciding where our application begins and ends around it.

I prefer architectures where HTTP remains an adapter, application workflows are visible, persistence is not hidden without a reason, external dependencies have explicit failure semantics, long-running work has the durability it needs, and observability follows the same boundaries as the code.

None of this requires a particular number of projects or a named architecture. It requires being able to explain why each important boundary exists. If the explanation is only “this is how we always structure .NET solutions”, that is usually where I start asking questions.

References