Basic Knowledge of EntityFramework for .Net Developer

I want to share essential knowledge about EntityFramework (EF), which should be enough to implement most of the trivial tasks working with the relational database. This article should help young developers to understand EF better and avoid some mistakes, which I did on my way.

basic entity framework knowledge

Preparation

Firstly let’s prepare a database. It reflects the most trivial case with the post and Author. I decided not to invent the wheel because this article reveals the primary usage of EF and not about some concrete model.

Let`s take a look at database schema:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- script to create database and required tables

create table authors(
id uuid primary key,
name text
);

create table posts (
id serial primary key,
title text,
content text,
is_deleted bool,
author_id uuid
);

alter table posts
add constraint posts_authors_fk foreign key (author_id) references authors(id);

In the project, we need to include proper NuGet packages:

1
dotnet add package Microsoft.EntityFrameworkCore -v 6.0.0

Besides the EF package, we should include specific NuGet for proper database dialect. We used the Npgsql package and Postgres as a database for examples in this article:

1
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL -v 6.0.0

EF initial configuration

We are ready to use EF in our project, and it’s time to configure POCO classes that will represent database objects in the .NET world:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Author
{
public Guid Id { get; set; }
public string Name { get; set; }
public virtual ICollection<Post> Posts { get; set; }
}

public class Post
{
public int Id { get; set; }
public string Title { get; set; }
public string Content { get; set; }

public bool? IsDeleted { get; set; }

public Guid AuthorId { get; set; }
public virtual Author Author { get; set; }
}

As you can see, classes represent almost the same structure as a table in the database. A difference comes with virtual properties. In the case of Author, it’s a list of Posts, and accordingly, the Post entity has virtual Author property. Such properties, also called Navigation properties, allow us to work with flat tables in the objects world.

We need to provide proper configuration for EF to make it understand navigation properties. Also, the Post table has a serial type in the database, and we need to tell EF about this. Furthermore, we should properly map all properties to columns.

I want to share the knowledge that EF gives us a way to provide a mapping between objects and tables in a few different ways. I want to leave entity objects simple as they are right now and not overwhelm them with many attributes. Fluent API provided by EF gives such ability. When you try to make it more straightforward and more visible for the future developer, it’s better to separate DB mappings in different scoped files. Another good point is to make classes for mapping internal and prevent access to them from other projects in the solution.

1
2
3
4
5
6
7
8
9
10
11
12
13
internal class AuthorMap
{
public static void Map(ModelBuilder modelBuilder)
{
var table = modelBuilder.Entity<Author>().ToTable("authors");

table.HasKey(t => t.Id);
table.Property(p => p.Id).HasColumnName("id");
table.Property(p => p.Name).HasColumnName("name");

table.HasMany(t => t.Posts).WithOne(t => t.Author);
}
}

We defined what property is the primary key for the table and mapped properties accordingly in such mapping. In the end, we described a navigation property and helped EF to understand how entities related to each other.

note: we’ll use this mapping file a bit later

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
internal class PostMap
{
public static void Map(ModelBuilder modelBuilder)
{
var sequenceName = "posts_id_seq";
modelBuilder.HasSequence<int>(sequenceName).IncrementsBy(1);
var table = modelBuilder.Entity<Post>().ToTable("posts");
table.HasQueryFilter(t => t.IsDeleted != true);

table.HasKey(k => k.Id);
table.Property(t => t.Id).HasColumnName("id").UseHiLo(sequenceName);
table.Property(t => t.Title).HasColumnName("title");
table.Property(t => t.Content).HasColumnName("content");
table.Property(t => t.IsDeleted).HasColumnName("is_deleted");
table.Property(t => t.AuthorId).HasColumnName("author_id");

table.HasOne(t => t.Author).WithMany(t => t.Posts).HasForeignKey(t => t.AuthorId);
}
}

Our mapping for Posts looks similar to authors, and you can notice a sequence name and some code related to it. It comes from PostgreSQL, and if you use another database server, you need to provide another configuration for the autoincremented column.

And finally, we can create our database context:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ExampleDbContext : DbContext
{
private readonly string _connectionString;
public ExampleDbContext(string connectionString)
{
_connectionString = connectionString;
}

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(_connectionString);

base.OnConfiguring(optionsBuilder);
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
PostMap.Map(modelBuilder);
AuthorMap.Map(modelBuilder);
base.OnModelCreating(modelBuilder);
}
}

Small Checklist

What you should avoid during EF configuration:

  • selecting name for your .net entities adhere DB table name and don’t use synonyms for this
  • previous rule is also applicable for navigation properties
  • try to separate DB mapping from each other it helps read/review code later

Let`s finally use it

We need to verify that it works and the best way to do it is to start using it.

Note: We should asynchronously implement all IO-bound operations; EF provides asynchronous analog to the most supported API.

Add entities to the database

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public static async Task AddEntityToDatabaseAsync()
{
var connectionString = "Server=localhost;Port=5432;Database=test_ef;User Id=postgres;Password=[YOUR_PASSWORD];";
using var context = new ExampleDbContext(connectionString);

var author = new Author
{
Id = Guid.NewGuid(),
Posts = new List<Post>(),
Name = "example author"
};
// at this point entity added to EF and it still does not exist in database
await context.AddAsync(author);

// we need to save changes and after this invocation entity will be added to database
await context.SaveChangesAsync();

// identical way to insert entity to database

var newAuthor = new Author
{
Id = Guid.NewGuid(),
Posts = new List<Post>(),
Name = "example author with attach"
};
var authorsSet = context.Set<Author>();
var attachedEntity = authorsSet.Attach(newAuthor);
attachedEntity.State = EntityState.Added;

await context.SaveChangesAsync();
}

Under the hood of AddAsync, EF does something similar to the last approach, and it prepares a set for the proper entity type and attaches the entity with status Added. ChangeTracker tracks these changes, and when you call SaveChangesAsync, it sends all data to the database.

Note: if you are working with web servers, try to save changes at the end of the HTTP request. It helps to protect data integrity and prevent an excess round trip to the database.

Get entities from the database

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static async Task GetEntitiesFromDatabaseAsync()
{
var connectionString = "Server=localhost;Port=5432;Database=test_ef;User Id=postgres;Password=[YOUR_PASSWORD];";
using var context = new ExampleDbContext(connectionString);

var author = new Author
{
Id = Guid.NewGuid(),
Posts = new List<Post>(),
Name = "example author"
};
await context.AddAsync(author);
await context.SaveChangesAsync();

var createdAuthorId = author.Id;
// returns object which should be cast to proper type
var createdAuthor = await context.FindAsync(typeof(Author), createdAuthorId);

var entities = context.Set<Author>();
// returns strongly typed object because we query typed entities set
var typedCreatedAuthor = await entities.FindAsync(createdAuthorId);
// allows to filter set by any criteria
var filterSEtByCriteria = await entities.AsNoTracking().FirstOrDefaultAsync(a => a.Id == createdAuthorId);

// change tracking because per request instance
context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}

As you can see, when you know the key, you can extract the entity from the database in a few ways. In most cases, FirstOrDefaultAsync helps but be aware of tracking settings. If you don’t mark a request to database AsNoTracking or set it up on context level, EF puts your entity to ChangeTracker. It requires additional resources from the system for reading flow; it’s unnecessary.

Note: Always use AsNotracking for reading operations if you are not going to modify extracted entities later

Load data with navigation properties

By default, when you load entity EF doesn’t load navigation properties to memory. When you access the navigation property, the possibility to get NullReferenceException is high. It is pretty convenient to load entity together with navigation properties just once and use it for in-memory business logic operations.

Note: Be aware of lazy loading. It can cause performance problems that are hard to identify.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static async Task LoadNavigationPropertyAsync(ExampleDbContext context, int postId)
{
// without include
var postsSets = context.Set<Post>();

var postWithoutAuthor = await postsSets.FirstOrDefaultAsync(p => p.Id == postId);
if (postWithoutAuthor.Author == null)
{
Console.WriteLine("Loading without include causes null for navigation property");
}

var postWithAuthor = await postsSets.Include(p => p.Author)
.FirstOrDefaultAsync(p => p.Id == postId);

if (postWithAuthor.Author != null)
{
Console.WriteLine("Loading with include gets navigation data from database");
}
}

As you see, we can use Include to specify which navigation properties we want to load from the database.

Use navigation properties to insert data

EF is quite clever in understanding the relationship between objects and providing proper values for foreign keys.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static async Task ExampleOfNavigationInsertAsync(ExampleDbContext context)
{
// add nested entity via navigation property
using var transaction = await context.Database.BeginTransactionAsync();
var author = new Author
{
Id = Guid.NewGuid(),
Posts = new List<Post>(),
Name = "example author"
};
author.Posts.Add(new Post
{
Content = "content1",
Title = "title",
// AuthorId = author.Id no need to provide it here
});
var authors = context.Set<Author>();
await authors.AddAsync(author);
await context.SaveChangesAsync();
await transaction.CommitAsync();
}

As you can see, we can omit AuthorId and EF provides it for us.

Another proper technique that you can notice here is wrapping modified flow into transaction scope. In case of a problem, we can roll back the transaction, and data in the database stays not corrupted.

Note: Working with web servers, try to wrap calls that should modify data into transactions and prevent any corrupted data from being inserted into the database.

Conclusion

EF as ORM simplifies life and works with databases smoothly. In this article, you can find base examples of configuring and working with EF. Some examples contain note which helps you to improve your day-to-day interactions with EF. I will try to prepare all the best practices on using EF in some next articles.