Why Repository Abstractions Usually Hurt Blazor Apps More Than Help “You didn’t decouple EF Core. You just hid it badly.”

 “You didn’t decouple EF Core. You just hid it badly.”


I’ve built this architecture.
More than once.

Early in my Blazor projects, adding repository abstractions felt like the responsible thing to do. The code looked clean. The layers were clear. Code reviews were easy.

Then the app grew.
The team changed.
Performance started to matter.

That’s when the repository layer stopped helping—and started getting in the way.

This article isn’t about theory. It’s about what I’ve learned the hard way, maintaining long-lived Blazor applications.


Why I Used Repositories (And Why They Felt Right)

I didn’t introduce repositories because I was careless.

I did it because:

  • Books recommended them
  • Talks showcased them
  • Juniors expected them
  • “Clean Architecture” diagrams normalized them

And early on?
They worked.

That’s the trap.


The Year-Two Problem

The pain never shows up in month three.

It shows up when:

  • Queries need tuning
  • Pages get slower
  • Features overlap
  • New developers join
  • Nobody remembers how data really flows

I’d see code like this:

var order = await _repository.GetByIdAsync(id);

order.Complete();

await _repository.UpdateAsync(order);

And ask myself:

  • What does this query load?
  • Is it tracked?
  • Does it include relationships?
  • Why is this page slow?

The repository didn’t answer those questions.

It hid them.


The Decoupling Myth

Here’s the uncomfortable truth:

We were never decoupled from EF Core.

  • EF Core still tracked entities
  • EF Core still dictates performance
  • EF Core behavior still leaks everywhere

The repository didn’t remove the dependency.

It blurred responsibility.

And blurred responsibility is where bugs and performance problems hide.


Generic Repositories Create Performance Blindness

Blazor apps are sensitive to data shape.

Generic repositories push teams toward:

  • GetAll()
  • Find()
  • In-memory filtering
  • Accidental over-fetching

When performance degrades, no one knows why—because the query is abstracted away.

Visibility matters more than reuse in Blazor.


Cargo Cult “Clean Architecture”

One of the hardest lessons I’ve learned is how easily patterns become rituals.

Junior developers were taught:

“This is how professional systems are built.”

So repositories multiplied.
Interfaces wrapped interfaces.
Services forwarded calls.
No one owned behavior.

Refactoring became risky—not because the domain was complex, but because the architecture was.

That’s not clean architecture.
That’s architectural superstition.


The Question That Changed Everything

Eventually, I started asking one simple question:

What volatility does this abstraction protect me from?

In most Blazor apps:

  • EF Core is stable
  • The database isn’t changing
  • The domain is evolving

Abstracting EF didn’t protect me from change.
It slowed my response to it.


What I Do Now: Use EF Core Directly (But Correctly)

When I stopped hiding EF Core, I had to be more intentional.

And in Blazor, that means owning DbContext lifetime explicitly.

Why DbContextFactory Is the Default in Blazor

Blazor components and services:

  • Live longer than HTTP requests
  • Can re-render repeatedly
  • May execute concurrently

DbContext is not thread-safe and not designed for long-lived usage.

So today, I treat IDbContextFactory<TContext> as the default.


Reading Data: Query Exactly What the UI Needs

public sealed class OrderSummaryService

{

    private readonly IDbContextFactory<AppDbContext> _dbFactory;

 

    public OrderSummaryService(IDbContextFactory<AppDbContext> dbFactory)

    {

        _dbFactory = dbFactory;

    }

 

    public async Task<OrderSummaryDto> GetAsync(Guid orderId)

    {

        await using var db = await _dbFactory.CreateDbContextAsync();

 

        return await db.Orders

            .AsNoTracking()

            .Where(o => o.Id == orderId)

            .Select(o => new OrderSummaryDto

            {

                OrderId = o.Id,

                Number = o.OrderNumber,

                Status = o.Status,

                Total = o.Lines.Sum(l => l.Quantity * l.UnitPrice)

            })

            .SingleAsync();

    }

}

Why this works:

  • No over-fetching
  • No hidden includes
  • No tracking leaks
  • Performance is visible

When something is slow, I know exactly where to look.


Writing Data: Load, Mutate, Save

public sealed class CompleteOrderService

{

    private readonly IDbContextFactory<AppDbContext> _dbFactory;

 

    public CompleteOrderService(IDbContextFactory<AppDbContext> dbFactory)

    {

        _dbFactory = dbFactory;

    }

 

    public async Task CompleteAsync(Guid orderId)

    {

        await using var db = await _dbFactory.CreateDbContextAsync();

 

        var order = await db.Orders

            .SingleAsync(o => o.Id == orderId);

 

        order.Complete();

 

        await db.SaveChangesAsync();

    }

}

No repositories.
No Update() calls.
No guessing about the state.

EF Core already knows what changed.


Where This Code Lives Matters

This code does not live:

  • In UI components
  • In generic services
  • In shared utility layers

It lives:

  • Near the feature it supports
  • Close to the UI
  • Inside vertical slices

That proximity keeps reasoning cheap.


When I Still Use Repositories

I haven’t banned repositories.

I’ve narrowed them.

I use them when:

  • There’s a rich domain
  • Aggregates enforce invariants
  • Persistence rules matter

public interface OrderRepository

{

    Task<Order> LoadForCompletion(Guid id);

    Task Save(Order order);

}

This expresses business intent, not CRUD.


Testing Reality

I used to justify repositories for testing.

In practice:

  • Fake repositories lied
  • Integration tests caught real bugs
  • SQLite + EF Core worked fine

Understanding beat abstraction.


The Real Cost I Didn’t Expect

The highest cost wasn’t performance.

It was mental overhead.

Every new developer had to reconstruct:

  • Where the data came from
  • What queries actually ran
  • Why behavior differed

Good architecture reduces thinking.
Bad abstraction multiplies it.


Final Thought

Repository abstractions aren’t evil.

But in Blazor apps, they’re often optimistic architecture—designed for a future that never arrives.

If I could give my younger self one rule, it would be this:

Favor visibility over abstraction.
Control lifetimes explicitly.
Let EF Core be honest.

Your Blazor app will be simpler.
Your performance issues will be obvious.
And year two will hurt a lot less.


[source code]


Comments

Popular posts from this blog

Customizing PWA Manifest and Icons for a Polished User Experience 🚀

Yes, Blazor Server can scale!

Offline-First Strategy with Blazor PWAs: A Complete Guide 🚀