A travel platform has already split pricing, reservations, and catalog management into separate backend services. All external traffic passes through an API gateway, and the catalog administration portal runs as an ASP.NET Core MVC application.
The architecture looks modular until the portal starts growing. Controllers query Entity Framework Core directly, update database entities, decide authorization rules, and notify other services. Every new screen adds more dependencies to the same web project. The backend was divided into microservices, but the frontend has become a second monolith behind the gateway.
The solution is not to create a separate frontend service for every page. It is to give the catalog frontend one coherent business boundary, keep its controllers focused on HTTP interaction, move commands and queries into application services, enforce business rules inside aggregates, and isolate EF Core in a database driver.
Context and Scope
The example system manages travel offers.
Users need to:
- View available offers
- Create and edit offers
- Change prices and validity dates
- Delete expired offers
- See different data according to their claims
Other services need to know when an offer price changes or an offer is deleted.
The architecture contains these components:
Browser or Client Application
|
v
API Gateway or Web Proxy
|
+--> Authentication Server
|
v
ASP.NET Core Catalog Frontend
|
+--> MVC Controllers and Views
|
+--> Application Commands and Queries
|
+--> Domain Aggregates and Events
|
+--> EF Core Database Driver
|
v
Catalog Database
|
v
Database-Backed Outgoing Event Queue
|
v
Other Microservices
The expected result is a frontend microservice that can be deployed and maintained independently without becoming a dumping ground for presentation, persistence, security, and business logic.
First Decide What the Frontend Owns
A frontend microservice communicates with users or external clients. A worker microservice performs background work without direct user interaction.
There are two common frontend forms:
| Frontend type | Main responsibility |
|---|---|
| Public web API | Expose operations to browser, mobile, desktop, or partner clients |
| HTML frontend | Build server-rendered pages and handle user interaction |
Public APIs are normally placed behind an API gateway. The gateway gives clients one external domain and can centralize concerns such as:
- Authentication token validation
- Response caching
- Request translation
- API version routing
- Documentation
- Load balancing
An HTML application may also sit behind a web proxy that routes requests to the correct frontend according to the URL.
Do not confuse the gateway with the frontend microservice. The gateway controls entry into the system. The frontend implements use cases and business-facing behavior.
Do Not Split the UI More Than the Business Requires
Several frontend applications can cooperate by owning different groups of pages. For example:
/admin/catalog/* -> Catalog Frontend
/admin/reservations/* -> Reservation Frontend
/admin/accounts/* -> Account Frontend
This URL-based split is simpler than composing every page from several independent frontends.
A true micro-frontend is needed only when one page contains areas owned by different business services and no single frontend can build that page efficiently.
There are two major composition approaches.
Server-side composition
An interface frontend owns the layout, requests fragments from several micro-frontends, assembles the complete page, and sends it to the browser only when it is ready.
Browser
|
v
Interface Frontend
|
+--> Catalog Fragment
+--> Reservation Fragment
+--> Account Fragment
|
v
Complete Page
This avoids visible page rearrangement while content arrives. The cost is that the interface frontend must hold the request while all fragments are collected. It must also understand the page pattern well enough to coordinate the fragments.
Client-side composition
A master client loads smaller applications that fill predefined areas in the page. Each small application can communicate through the API gateway with the service it owns.
This provides stronger lifecycle independence, especially when each micro-application runs in an isolated client scope. The user experience is harder to coordinate because content may arrive at different times and cause the page to move.
Pre-allocating fixed areas and displaying temporary placeholders can reduce visible layout changes.
Use micro-frontends when independent scaling and release lifecycles justify the coordination cost. For a normal catalog administration area owned by one team and one bounded context, one ASP.NET Core frontend is often the cleaner choice.
Organize the Frontend with Onion Architecture
The ASP.NET Core web project should not contain the complete application.
Use four logical layers:
Outermost Layer
- ASP.NET Core MVC
- EF Core Database Driver
|
v
Application Services
- Commands
- Queries
- Command handlers
- Domain event handlers
|
v
Domain Services
- Repository interfaces
- Unit of work abstraction
- Read DTOs
|
v
Domain Model
- Aggregates
- State interfaces
- Business rules
- Domain events
Each layer can be implemented as a separate project or assembly.
Dependencies point inward. The domain model does not reference ASP.NET Core or Entity Framework Core. The database driver references the domain abstractions and provides their implementations.
This structure makes the frontend independently deployable without making every concern dependent on the MVC project.
Model Business Changes Inside an Aggregate
Assume an offer price change must produce an event for reservation and search services. The business rule belongs inside the aggregate, not in the controller.
Define a state interface in the domain project:
public interface IOfferState
{
int Id { get; }
string Name { get; set; }
decimal Price { get; set; }
DateTime? ValidFrom { get; set; }
DateTime? ValidUntil { get; set; }
long Version { get; set; }
}
The aggregate wraps the state and controls changes:
public sealed class OfferAggregate
{
private readonly IOfferState _state;
private readonly List<object> _events = [];
public OfferAggregate(IOfferState state)
{
_state = state;
}
public int Id => _state.Id;
public string Name => _state.Name;
public decimal Price => _state.Price;
public long Version => _state.Version;
public IReadOnlyCollection<object> Events => _events;
public void ChangePrice(decimal newPrice)
{
if (newPrice < 0)
{
throw new ArgumentOutOfRangeException(nameof(newPrice));
}
if (newPrice == _state.Price)
{
return;
}
var oldVersion = _state.Version;
var newVersion = oldVersion + 1;
_state.Price = newPrice;
_state.Version = newVersion;
_events.Add(new OfferPriceChanged(
Id,
newPrice,
oldVersion,
newVersion));
}
}
public sealed record OfferPriceChanged(
int OfferId,
decimal NewPrice,
long OldVersion,
long NewVersion);
The controller cannot accidentally change the price without applying the domain rule and producing the event.
The version values help other services process asynchronous changes in the correct order. A receiving service can detect that an expected version is missing or that an older update arrived after a newer one.
Keep Repository Interfaces Small
Repositories retrieve and create aggregates. They should not become large service classes containing every business operation.
public interface IOfferRepository
{
Task<OfferAggregate?> FindAsync(
int offerId,
CancellationToken cancellationToken);
OfferAggregate Create(
string name,
decimal price,
DateTime? validFrom,
DateTime? validUntil);
Task<OfferAggregate?> DeleteAsync(
int offerId,
CancellationToken cancellationToken);
}
Methods such as ChangePriceAsync, ExtendValidityAsync, and RenameOfferAsync belong on the aggregate when they represent business behavior.
Read operations do not always need aggregates. A list screen can use a dedicated Data Transfer Object, or DTO:
public sealed record OfferListItemDto(
int Id,
string Name,
decimal Price,
DateTime? ValidUntil);
This separation supports Command Query Responsibility Segregation, or CQRS:
- Commands change state through aggregates.
- Queries return models shaped for reading.
- The read path does not load an updateable aggregate unless it needs one.
Isolate EF Core in the Database Driver
The database driver contains:
- EF Core entities
- The
DbContext - Entity configuration
- Repository implementations
- Migration support
An EF Core entity can implement the domain state interface:
public sealed class OfferEntity : IOfferState
{
public int Id { get; set; }
public required string Name { get; set; }
public decimal Price { get; set; }
public DateTime? ValidFrom { get; set; }
public DateTime? ValidUntil { get; set; }
[ConcurrencyCheck]
public long Version { get; set; }
}
The concurrency marker allows EF Core to detect that another request changed the version after the entity was read. The application can then reject or retry the operation instead of silently overwriting a newer update.
This avoids keeping a long blocking transaction open while the user edits a form.
The repository loads the entity and returns an aggregate that wraps it:
public async Task<OfferAggregate?> FindAsync(
int offerId,
CancellationToken cancellationToken)
{
var entity = await _context.Offers.FindAsync(
[offerId],
cancellationToken);
return entity is null
? null
: new OfferAggregate(entity);
}
Because the aggregate modifies the tracked entity through IOfferState, SaveChangesAsync() can persist the changes without copying them back into another object.
Use Commands and Queries Between Controllers and Persistence
Controllers should not coordinate repositories and transactions directly.
A query object supports the read path:
public interface IOfferListQuery
{
Task<IReadOnlyList<OfferListItemDto>> ExecuteAsync(
CancellationToken cancellationToken);
}
A command describes one requested change:
public sealed record ChangeOfferPriceCommand(
int OfferId,
decimal NewPrice);
The command handler loads the aggregate, applies the rule, stores outgoing events, and commits the transaction.
public sealed class ChangeOfferPriceHandler
{
private readonly IOfferRepository _offers;
private readonly IOutgoingEventRepository _events;
private readonly IUnitOfWork _unitOfWork;
public ChangeOfferPriceHandler(
IOfferRepository offers,
IOutgoingEventRepository events,
IUnitOfWork unitOfWork)
{
_offers = offers;
_events = events;
_unitOfWork = unitOfWork;
}
public async Task HandleAsync(
ChangeOfferPriceCommand command,
CancellationToken cancellationToken)
{
var offer = await _offers.FindAsync(
command.OfferId,
cancellationToken);
if (offer is null)
{
throw new InvalidOperationException(
"The offer was not found.");
}
offer.ChangePrice(command.NewPrice);
foreach (var domainEvent in offer.Events)
{
_events.Add(domainEvent);
}
await _unitOfWork.SaveChangesAsync(cancellationToken);
}
}
The offer update and the outgoing event are stored in the same local transaction. A background publisher can later forward queued events to interested services.
This prevents a dangerous gap where the database update succeeds but the notification is lost.
Keep MVC Controllers Thin
A controller handles the web interaction:
- Receive input
- Trigger model validation
- Call a query or command handler
- Select the next view or response
A list action can request its query from dependency injection:
[HttpGet]
public async Task<IActionResult> Index(
[FromServices] IOfferListQuery query,
CancellationToken cancellationToken)
{
var items = await query.ExecuteAsync(cancellationToken);
return View(new OfferListViewModel
{
Items = items
});
}
A write action validates the ViewModel and sends a command:
[HttpPost]
public async Task<IActionResult> ChangePrice(
ChangeOfferPriceViewModel model,
[FromServices] ChangeOfferPriceHandler handler,
CancellationToken cancellationToken)
{
if (!ModelState.IsValid)
{
return View(model);
}
await handler.HandleAsync(
new ChangeOfferPriceCommand(
model.OfferId,
model.NewPrice),
cancellationToken);
return RedirectToAction(nameof(Index));
}
The controller does not know how EF Core tracks the entity, how the transaction is committed, or how other services receive the event.
This keeps HTTP behavior testable and prevents presentation concerns from leaking into the domain.
Centralize Login but Keep Authorization Close to the Use Case
A system with several frontend microservices should use one authentication authority. Users should not have separate login implementations for each frontend.
The exact flow depends on how frontends are exposed.
Directly accessible HTML frontends
An authentication server can use OAuth to establish the user's identity. The HTML frontend can then use a cookie suitable for browser interaction.
Each frontend still decides whether the authenticated user may execute its operations.
Frontends behind one interface service or gateway
The gateway can authenticate incoming requests and perform broad URL-level authorization. It should still forward an authorization token to the destination frontend.
The downstream frontend needs the user's claims because authorization may depend on:
- A path or request parameter
- The resource being edited
- The user's organization
- Which records the user is allowed to see
- A claim that the gateway cannot evaluate from the URL alone
User
|
v
Gateway validates identity
|
v
Gateway forwards bearer token
|
v
Catalog Frontend evaluates claims
|
v
Command or Query executes
Do not trust only the gateway for fine-grained authorization. The frontend owns the use case and has the data needed to make the final decision.
Register authentication and authorization services, and include their middleware in the ASP.NET Core request pipeline before protected controller endpoints execute.
Test Each Boundary Independently
A layered frontend is easier to test because each boundary has a focused responsibility.
Aggregate tests
Verify that:
- Negative prices are rejected.
- Setting the same price does not create an event.
- A valid price change increases the version.
- The event contains the old and new versions.
Command handler tests
Use fake repository and unit-of-work implementations to verify that:
- The handler loads the correct aggregate.
- The aggregate rule is called.
- Outgoing events are stored.
- Changes are committed once.
- A missing offer produces the expected failure.
Controller tests
Replace the query or handler with a fake and verify that:
- Invalid input redisplays the form.
- Valid input creates the correct command.
- The list action maps query output into the ViewModel.
- Controllers do not invoke EF Core directly.
Persistence tests
Use the real EF Core provider where database behavior matters and verify that:
- Entities implement the domain state contracts.
- Concurrency conflicts are detected.
- Offer updates and outgoing events commit together.
- Repository queries return the expected DTOs.
Authorization tests
Verify that:
- Unauthenticated requests are rejected.
- A valid identity without the required claim cannot execute the operation.
- Different claims produce the correct filtered query results.
- Forwarded bearer tokens reach the destination frontend when a gateway is used.
Common Mistakes
Treating the API gateway as the application layer
The gateway should route and centralize cross-cutting concerns. It should not contain catalog business rules.
Letting controllers use DbContext directly
This joins HTTP handling, persistence, transactions, and business behavior in one class.
Creating a micro-frontend for every page
Independent page fragments add composition and user-experience costs. Split by business ownership, scaling, and release needs.
Building one custom aggregator for every combined screen
A special-purpose aggregator can create a good page, but it becomes tightly coupled to every source service it combines.
Trusting gateway authorization alone
The gateway may know the route but not the resource values or claims needed for a detailed authorization decision.
Publishing events after the database transaction
A crash between the database commit and message publication can leave other services unaware of a completed change.
Returning aggregates to MVC views
Views need presentation models, not updateable domain objects.
Putting business methods on repositories
Repositories retrieve and create aggregates. Aggregates enforce business changes.
Using long database transactions for user interaction
Users may keep an edit form open for a long time. Use version-based concurrency checks when saving instead of locking the row for the entire interaction.
Frontend Microservice Checklist
- [ ] Define one coherent bounded context for the frontend
- [ ] Distinguish the API gateway from the frontend service
- [ ] Split page ownership by business capability
- [ ] Use true micro-frontends only when one page crosses several ownership boundaries
- [ ] Keep the domain model free from ASP.NET Core and EF Core dependencies
- [ ] Put EF Core entities and repositories in a database driver
- [ ] Use aggregates for business changes
- [ ] Keep repository interfaces small
- [ ] Separate commands from read queries
- [ ] Return DTOs and ViewModels on read paths
- [ ] Keep controllers focused on validation and interaction flow
- [ ] Store outgoing events in the same transaction as business changes
- [ ] Add version information to externally propagated entity changes
- [ ] Use concurrency checks instead of long user-facing transactions
- [ ] Centralize login through one authentication authority
- [ ] Forward identity claims to downstream frontends
- [ ] Perform fine-grained authorization inside the owning frontend
- [ ] Test domain, application, web, persistence, and authorization boundaries separately
Conclusion
An API gateway does not prevent an ASP.NET Core frontend from becoming a monolith. It only gives external traffic a controlled entry point.
The frontend remains maintainable when it owns one business capability, uses micro-frontends only where independent page ownership justifies them, keeps controllers thin, separates commands from queries, enforces rules through aggregates, and isolates EF Core in a database driver.
Centralized authentication, downstream claim-based authorization, concurrency checks, and a database-backed outgoing event queue complete the boundary. The result is a frontend microservice that can evolve independently without collecting every responsibility in the MVC project.