M365 Show -  Microsoft 365 Digital Workplace Daily
M365 Show with Mirko Peters - Microsoft 365 Digital Workplace Daily
Stop Using Entity Framework Like This
0:00
-18:23

Stop Using Entity Framework Like This

If you’re using Entity Framework only to mirror your database tables into DTOs, you’re missing most of what it can actually do. That’s like buying an electric car and never driving it—just plugging your phone into the charger. No wonder so many developers end up frustrated, or decide EF is too heavy and switch to a micro-ORM.

Here’s the thing: EF works best when you use it to persist meaningful objects instead of treating it as a table-to-class generator. In this podcast, I’ll show you three things: a quick before-and-after refactor, the EF features you should focus on—like navigation properties, owned types, and fluent API—and clear signs that your code smells like a DTO factory.

And when we unpack why so many projects fall into this pattern, you’ll see why EF often gets blamed for problems it didn’t actually cause.

The Illusion of Simplicity

This is where the illusion of simplicity comes in. At first glance, scaffolding database tables straight into entity classes feels like the fastest way forward. You create a table, EF generates a matching class, and suddenly your `Customer` table looks like a neat `Customer` object in C#. One row equals one object—it feels predictable, even elegant. In many projects I’ve seen, that shortcut is adopted because it looks like the most “practical” way to get started. But here’s the catch: those classes end up acting as little more than DTOs. They hold properties, maybe a navigation property or two, but no meaningful behavior. Things like calculating an order total, validating a business rule, or checking a customer’s eligibility for a discount all get pushed out to controllers, services, or one-off helper utilities. Later I’ll show you how to spot this quickly in your own code—pause and check whether your entities have any methods beyond property getters. If the answer is no, that’s a red flag.

The result is a codebase made up of table-shaped classes with no intelligence, while the real business logic gets scattered across layers that were never designed to carry it. I’ve seen teams end up with dozens, even hundreds, of hollow entities shuttled around as storage shells. Over time, it doesn’t feel simple anymore. You add a business rule, and now you’re diffing through service classes and controllers, hoping you don’t break an existing workflow. Queries return data stuffed with unnecessary columns, because the “model” is locked into mirroring the database instead of expressing intent. At that point EF feels bloated, as if you’re dragging along a heavy framework just to do the job a micro-ORM could do in fewer lines of code. And that’s where frustration takes hold—because EF never set out to be just a glorified mapper. Reducing it to that role is like carrying a Swiss Army knife everywhere and only using the toothpick: you bear the weight of the whole tool without ever using what makes it powerful.

The mini takeaway is this: the pain doesn’t come from EF being too complex, it comes from using it in a way it wasn’t designed for. Treated as a table copier, EF actively clutters the architecture and creates a false sense of simplicity that later unravels. Treated as a persistence layer for your domain model, EF’s features—like navigation properties, owned types, and the fluent API—start to click into place and actually reduce effort in the long run.

But once this illusion sets in, many teams start looking elsewhere for relief. The common story goes: "EF is too heavy. Let’s use something lighter." And on paper, the alternative looks straightforward, even appealing.

The Micro-ORM Mirage

A common reaction when EF starts to feel heavy is to reach for a micro-ORM. From experience, this option can feel faster and a lot more transparent for simple querying. Micro-ORMs are often pitched as lean tools: lightweight, minimal overhead, and giving you SQL directly under your control. After dealing with EF’s configuration layers or the way it sometimes returns more columns than you wanted, the promise of small and efficient is hard to ignore. At first glance, the logic seems sound: why use a full framework when you just want quick data access?

That appeal fits with how many developers start out. Long before EF, we learned to write straight SQL. Writing a SELECT statement feels intuitive. Plugging that same SQL string into a micro-ORM and binding the result to a plain object feels natural, almost comfortable. The feedback loop is fast—you see the rows, you map them, and nothing unexpected is happening behind the scenes. Performance numbers in basic tests back up the feeling. Queries run quickly, the generated code looks straightforward, and compared to EF’s expression trees and navigation handling, micro-ORMs feel refreshingly direct. It’s no surprise many teams walk away thinking EF is overcomplicated.

But the simplicity carries hidden costs that don’t appear right away. EF didn’t accumulate features by mistake. It addresses a set of recurring problems that larger applications inevitably face: managing relationships between entities, handling concurrency issues, keeping schema changes in sync, and tracking object state across a unit of work. Each of these gaps shows up sooner than expected once you move past basic CRUD. With a micro-ORM, you often end up writing your own change tracking, your own mapping conventions, or a collection of repositories filled with boilerplate. In practice, the time saved upfront starts leaking away later when the system evolves.

One clear example is working with related entities. In EF, if your domain objects are modeled correctly, saving a parent object with modified children can be handled automatically within a single transaction. With a micro-ORM, you’re usually left orchestrating those inserts, updates, and deletes manually. The same is true with concurrency. EF has built-in mechanisms for detecting and handling conflicting updates. With a micro-ORM, that logic isn’t there unless you write it yourself. Individually, these problems may look like small coding tasks, but across a real-world project, they add up quickly.

The perception that EF is inherently harder often comes from using it in a stripped-down way. If your EF entities are just table mirrors, then yes—constructing queries feels unnatural, and LINQ looks verbose compared to a raw SQL string. But the real issue isn’t the tool; it’s that EF is running in table-mapper mode instead of object-persistence mode. In other words, the complexity isn’t EF’s fault, it’s a byproduct of how it’s being applied. Neglect the domain model and EF feels clunky. Shape entities around business behaviors, and suddenly its features stop looking like bloat and start looking like time savers.

Here’s a practical rule of thumb from real-world projects: Consider a micro-ORM when you have narrow, read-heavy endpoints and you want fine-grained control of SQL. Otherwise, the maintenance costs of hand-rolled mapping and relationship management usually surface down the line. Used deliberately, micro-ORMs serve those specialized needs well. Used as a default in complex domains, they almost guarantee you’ll spend effort replicating what EF already solved.

Think of it this way: choosing a micro-ORM over EF isn’t wrong, it’s just a choice optimized for specific scenarios. But expect trade-offs. It’s like having only a toaster in the kitchen—perfect when all you ever need is toast, but quickly limiting when someone asks for more.

The key point is that micro-ORMs and EF serve different purposes. Micro-ORMs focus on direct query execution. EF, when used properly, anchors itself around object persistence and domain logic. Treating them as interchangeable options leads to frustration because each was built with a different philosophy in mind.

And that brings us back to the bigger issue. When developers say they’re fed up with EF, what they often dislike is the way it’s being misused. They see noise and friction, but that noise is created by reducing EF to a table-copying tool. The question is—what does that misuse actually look like in code? Let’s walk through a very common pattern that illustrates exactly how EF gets turned into a DTO factory, and why that creates so many problems later.

When EF Becomes a DTO Factory

When EF gets reduced to acting like a DTO factory, the problems start to show quickly. Imagine a simple setup with tables for Customers, Orders, and Products. The team scaffolds those into EF entities, names them `Customer`, `Order`, and `Product`, and immediately begins using those classes as if they represent the business. At first, it feels neat and tidy—you query an order, you get an `Order` object. But after a few weeks, those classes are nothing more than property bags. The real rules—like shipping calculations, discounts, or product availability—end up scattered elsewhere in services and controllers. The entity objects remain hollow shells.

At this point, it helps to recognize some common symptoms of this “DTO factory” pattern. Keep an ear out for these red flags: your entities only contain primitive properties and no actual methods; your business rules get pulled into services or controllers instead of being expressed in the model; the same logic gets re‑implemented in different places across the codebase; and debugging requires hopping across multiple files to trace how a single feature really works. If any of these signs match your project, pause and note one concrete example—we’ll refer back to it in the demo later.

The impact of these patterns is pretty clear when you look at how teams end up working. Business logic that should belong to the entity ends up fragmented. Shipping rules, discount checks, and availability rules might each live in a different service or helper. These fragmented rules look manageable when the project is small, but as the system grows, nobody has a single place to look when they try to understand how it works. The `Customer` and `Order` classes tell you nothing about the business relationships they’re supposed to capture because they’ve been reduced to storage structures.

From here, maintainability starts to slide. A bug comes in about shipping calculations. You naturally check the `Customer` class, only to discover it has no behavior at all. You then chase references through billing helpers, shipping calculation services, and controller code. Fixes require interpreting an invisible web of dependencies. Over time, slight differences creep in—two developers might implement the same discount rule in two different ways without realizing it. Those inconsistencies are almost guaranteed when logic isn’t centralized. Testing suffers too; instead of unit testing clear domain behaviors, you have to mock out service networks just to verify rules that should have lived right inside the entity.

This structure also fuels the perception that EF itself is at fault. Teams often describe EF as “magical” or unpredictable, wondering why SaveChanges updated fields they thought were untouched, or why related entities loaded differently than expected. In practice, this unpredictability comes from using EF to track hollow objects. When entities are nothing but DTOs, their absence of intent makes EF’s behavior feel arbitrary. It isn’t EF misbehaving, it’s EF being asked to persist structures that never carried the business meaning they needed to.

The broader consequence is a codebase stuck in procedural mode. Instead of entities that carry their responsibilities, you get layers of procedural scripts hidden in services that impersonate a domain model. EF merely pushes and pulls these data bags to the database, but offers no leverage because the model itself doesn’t describe the actual domain. It’s not that EF failed—it’s that the model was never allowed to succeed.

The good news is that this pattern is not permanent. Refactoring away from EF-as-DTO means rethinking what goes into your entities. Instead of spreading behaviors across multiple services and controllers, you start to treat those objects as the true home for domain rules. The shift is concrete: order totals, eligibility checks, and shipping calculations live alongside the data they depend on. This change consolidates behavior into the model, making it discoverable, testable, and consistent.

That naturally raises the big question: how do we move from a library of hollow DTOs to real objects that express business rules, without giving up EF in the process?

Transforming into Proper OOP with EF

Transforming EF into an object-oriented tool starts by flipping the perspective. Instead of letting a database schema dictate the shape of your code, you treat your objects as the real center of gravity and let EF handle the persistence underneath. That doesn’t mean adding layers of ceremony or reinventing architectures. It simply means designing your entities to describe what the business actually does, while EF works in the background to translate that design into rows and columns.

For clarity, here’s the flow I’ll walk through in the demo: first, you’ll see a DTO‑style `Order` entity that only carries primitive properties. Then I’ll show you how the same `Order` looks once behavior is moved inside the object. Finally, we’ll look at how EF’s fluent API can persist that richer object without cluttering the domain class itself. Along the way, I’ll highlight three EF features that make this work: navigation properties, owned or value types, and fluent API configurations. Those are the practical tools that let you separate business intent from storage details.

Let’s make it concrete. In the hollow DTO model, an `Order` might have just an `Id`, a `CustomerId`, and a list of line items. All the real thinking—like the total price of the order—is pushed out into a service or utility. But in an object‑oriented approach, the `Order` includes a method like “calculate total,” which sums up the included line items and applies any business rules. Placing that method on the object matters: you remove duplication, you keep the calculation close to the data it depends on, and future developers can discover the logic where they expect it. Instead of guessing which service hides a calculation, they can look at the order itself.

Many developers hesitate here, worrying that richer domain objects will be harder to persist. That’s an understandable reaction if you’ve only seen EF used as a table‑to‑class mirroring tool. But persistence complexity is exactly what EF’s modern features are designed to absorb. Navigation properties handle relationships naturally. Owned types let you wrap common concepts like an Address or an Email into value objects without breaking persistence. And when you need precise control, the fluent API lets you define database‑specific rules—like decimal precision—without polluting your domain classes. The complexity doesn’t vanish, but it gets pushed into a clear boundary where EF can manage it directly.

The fluent API in particular acts as a clean translator. Your `Order` class can focus entirely on the business—rules for adding products, enforcing a warehouse constraint, exposing a property for free shipping eligibility—while the mapping configuration files quietly describe how those rules translate to the database. This keeps your business model tidy and makes persistence more predictable, because all the storage rules sit in one place instead of leaking across entity code.

If we scale the example up, the difference grows more obvious. Say an order has multiple line items, each tied to a product with its own constraints. In a DTO approach, you’d fetch the order and then pull in extra services to stitch everything together before applying rules. In a richer model, that work collapses into the entity itself. You can ask the order for its total, or check if it qualifies for free shipping, and the rules are applied consistently every time. EF persists the relationships behind the scenes, but you stay anchored in business logic rather than plumbing.

The benefits cascade outward. Logical duplication fades because rules live in one place. Tests become simpler—no more wiring up half a dozen services to verify that discounts apply correctly. Instead, you test an order directly. Debugging also improves: business rules are discoverable inside the entity where they belong, not scattered across controllers and helpers. EF continues doing what it does best—tracking changes and generating SQL—but now it works in service of a model that actually represents your business.

Here’s a small challenge you can try after watching: open one of your existing entities and ask yourself, “Could this responsibility live inside the object?” If the answer is yes, move one small piece of logic—like a calculation or a rule—into the entity and use EF mapping to persist it. That experiment alone can show the difference in clarity.

Once you’ve seen how to give entities real behavior, the next natural question is why the shift matters over time. Rewriting classes isn’t free, so let’s look at the longer‑term impact of doing EF in a way that aligns with object‑oriented design.

The Long-Term Value of Doing EF Right

So what do you actually gain when you stop treating EF as a DTO copier and start using it to back real objects? The long-term value comes down to three things: cleaner testing, less duplication to maintain, and far clearer code for the next developer who joins the project. Those three benefits may not feel dramatic in the short term, but over months and years they shape whether a codebase stays steady or drifts into constant rework.

The first big gain is easier testing. When objects know their own rules, you can test them directly without scaffolding services or mocking dependencies that shouldn’t even exist. An `Order` that calculates its own total can be exercised in isolation, giving you consistent results in small, fast-running tests. Updates or new behaviors are easier to verify because the logic lives exactly where the test points. As projects evolve, this pays off repeatedly—small changes are less risky since testing effort doesn’t balloon with every rule adjustment.

The second benefit is reducing duplication and scattered maintenance. In DTO-style systems, one business rule often gets repeated across multiple service methods and controllers. Change a discount formula in one place but forget another, and you’ve created a subtle bug. Centralizing logic inside the object removes that duplication at the source. Here’s a simple check you can try in your own project: when a business rule changes, count how many code files you edit. If the answer is more than one, you’ve likely fallen into duplication. That’s a measurable way to see if technical debt is creeping in.

The third benefit is clarity for onboarding and debugging. When EF is only storing DTO shells, new team members have to hunt through services to discover where rules are hidden. That slows them down. By contrast, when behavior sits in the object itself, the path is obvious. Debugging also shifts from hours of tracing service code to dropping one breakpoint inside the object method that enforces the rule. Before, you crossed multiple files to follow the logic. After, you look in one class and see the rule expressed cleanly. That contrast alone saves an enormous amount of wasted time for any team.

Performance is also tied to how you shape your models. With table clones, EF often drags back entire rows or related entities that you don’t even use. That costs memory and query time, particularly as data grows. But when the model reflects intent, you can project exactly what belongs in scope. Owned types let you model concepts like addresses without clutter, while selective includes load just what’s needed for the behavior you’re testing. The effect isn’t about micro-benchmarks; it’s the intuition that better-shaped models naturally lead to leaner queries.

None of this guarantees a perfect outcome. But in many long-lived projects, I’ve seen that teams who invest early in placing behavior inside models avoid the slow creep of duplicated rules and fragile service layers. Their tests stay lighter, their change costs stay lower, and onboarding looks more like reading straightforward domain objects instead of navigating a maze of procedural code. Teams that skipped that step often end up with technical debt that costs more to untangle than the up-front modeling would have. The pattern shows up again and again.

All of this feeds into the bigger picture: proper use of EF doesn’t just clean up the present, it improves how a project survives the future. Rich objects, backed by EF’s persistence features, create models that developers can trust, extend, and understand. That confidence saves teams from the churn of accidental complexity and restores EF to the role it was meant to play.

And this leads to the final point. The problem was never that EF itself was too large or too slow—it’s that we often narrow it down into something it was never supposed to be.

Conclusion

So here’s where everything comes together. EF works best when you use it to persist meaningful domain objects rather than empty DTO shells. If you reduce it to a table copier, you lose the advantages that make it worth using in the first place.

Keep three takeaways in mind: stop relying on EF as a table-to-class generator, put behavior back into your entities, and use EF’s mappings to take care of persistence details. Here’s a small challenge—pick one entity in your project and comment below: “DTO” or “Model,” along with why. And if this kind of practical EF and .NET guidance helps, subscribe for more focused patterns and real-world practices.

Discussion about this episode

User's avatar