Skip to main content

14 posts tagged with ".NET"

Runtime behaviour, GC and configuration details of .NET that only surface under real production load.

View All Tags

Unit of Work in .NET and ABP: SaveChangesAsync Is Not a Commit

· 12 min read
Nguyễn Huỳnh Minh Tiến
Middle Fullstack Developer @ Utop.vn
Summary

EF Core's DbContext is already a Unit of Work: it collects changes in the change tracker and pushes them to the database inside one transaction when you call SaveChanges. Writing your own IUnitOfWork purely to call SaveChanges is therefore usually redundant. ABP is a different matter entirely: there, the Unit of Work is ambient, opens automatically per request, and SaveChangesAsync inside a UoW does not commit the transaction — only CompleteAsync does. Misreading that one point is the source of most "the data is there sometimes" bugs.

Unit of Work is one of the most frequently reimplemented patterns in the .NET world, and also the one most frequently reimplemented for no reason. This post splits into two parts: what you actually need with plain EF Core, and what ABP has already done for you that you should understand before touching it.

When Does Calling .Result Deadlock, and When Does It Not?

· 14 min read
Nguyễn Huỳnh Minh Tiến
Middle Fullstack Developer @ Utop.vn
Summary

.Result and .Wait() block the current thread. When await suspends, it captures the current SynchronizationContext so that the code after it runs back on that same context. If the context only allows one thread at a time — a WPF UI thread, an ASP.NET Framework request context — and that thread is the one .Result is blocking, the continuation can never get in: deadlock. Console apps and ASP.NET Core have no SynchronizationContext, so the continuation runs on the thread pool and nothing gets stuck. The real fix is async all the way.

The code below runs perfectly in a console app, and freezes solid the moment you paste it verbatim into an ASP.NET Framework controller or a button handler in WPF:

public string GetCustomerName(int id)
{
return GetCustomerAsync(id).Result; // hangs here
}

private async Task<string> GetCustomerAsync(int id)
{
var response = await _http.GetStringAsync($"/customers/{id}");
return Parse(response).Name;
}

No exception, no stack trace, no timeout. The thread simply stops forever. This is not HttpClient's fault, and async is not "broken" — it is the direct consequence of a design decision in how await works.

Why Does EF Core Fire 201 Queries for One List Screen? Fixing N+1

· 13 min read
Nguyễn Huỳnh Minh Tiến
Middle Fullstack Developer @ Utop.vn
Summary

A 200-row list whose log shows 201 SQL statements is textbook N+1: one query fetches the list, then each row triggers another query to load its related data. The root cause is navigation properties being loaded one at a time instead of together. The default fix is a projection with Select into a DTO. Include is only the right tool when you need real entities, and once you Include two or more collections you need AsSplitQuery to avoid a cartesian explosion.

You open the customer list page, the API responds after four seconds, yet the database CPU barely moves and every individual SQL statement runs in a few milliseconds. Turn on logging and it turns out the problem is not one slow query, but 201 fast ones queued up nose to tail.

That is an N+1 query. It does not make the code wrong, it throws no exception, no unit test catches it, and on your local machine with twenty seeded rows it may even be faster than the correct fix. It only shows itself once the tables hold real data and the network latency to the database is non-zero.

Change the id in the URL and Get Someone Else's Data? Stop IDOR in a Single Layer

· 14 min read
Nguyễn Huỳnh Minh Tiến
Middle Fullstack Developer @ Utop.vn
Summary

IDOR happens when an endpoint takes an id from the request and loads the record directly, checking only that the caller is logged in rather than that the caller has rights over that particular record. Switching ids to GUIDs or encrypting them does not fix the bug, because the id still leaks somewhere else. The fix is to force every query through a layer that binds it to ownership on the server, instead of scattering checks through individual controllers.

You have a perfectly ordinary endpoint: GET /api/orders/1043 returns order details. It sits behind [Authorize], only a valid token gets through, and in the UI a user can only click into their own orders.

Then somebody logs in with their real account, changes 1043 to 1044, and hits send. If the server returns someone else's order, that is an Insecure Direct Object Reference — IDOR for short. No payload, no technique, just one number changed.

Singleton, Scoped or Transient? Get It Wrong and Your DbContext Lives Forever

· 14 min read
Nguyễn Huỳnh Minh Tiến
Middle Fullstack Developer @ Utop.vn
Summary

Choosing a lifetime means choosing when the container creates an instance and when it disposes it. Transient creates a new one on every resolve, Scoped gives one instance per HTTP request, Singleton one instance for the whole process. The single most important rule: a long-lived service must not take a short-lived one directly. Inject a Scoped repository into a Singleton and you have just kept that DbContext alive until the app shuts down. The fix is to inject IServiceScopeFactory and open a scope yourself when you need one.

The registration line that causes this looks so harmless that nobody pauses on it in review:

builder.Services.AddDbContext<CrmDbContext>(o => o.UseSqlServer(conn)); // Scoped
builder.Services.AddScoped<ICustomerRepository, EfCustomerRepository>();
builder.Services.AddSingleton<CustomerCacheRefresher>(); // ← the explosive

The app passes CI and reaches staging. Once it is under real load, the logs start showing A second operation was started on this context instance before a previous operation completed, or Cannot access a disposed context instance — or, worse, no exception at all and merely stale data that never changes. This post explains the mechanism behind it, with the code for both the broken and the fixed version.