Key Takeaways
- New projects should use Nullable: Enable from day one.
- All projects should use TreatWarningsAsErrors and FxCopAnalyzers.
- Don’t hesitate to disable static analysis rules that aren’t beneficial to your project.
- Fix issues incrementally so that you aren’t overwhelmed.
- Regenerating EF Core entities may require reapplying changes, so carefully review diffs before committing code.
One of the long-standing issues with Entity Framework is it doesn’t adhere to .NET’s design patterns. For example, Entity Framework adds setters to collection properties in violation of CA2227: Collection properties should be read only. These problems are compounded when you try to enable modern static analysis tools (e.g. Microsoft.CodeAnalysis.FxCopAnalyzers) and C# 8’s nullable reference types.
In this article we walk through the process of updating an EF Core 3.1 based DAL (data access layer) to adhere to modern best practices. Our sample data will be AdventureWorks2017, which is available from the official SQL Server samples site.
If you wish to skip to the completed example, refer to the EFCoreNullable project on GitHub.
Getting Started
This article assumes you are using .NET Core 3.1. If not, see Using C# 8 and Nullable Reference Types in .NET Framework for some of the possible considerations.
To begin we install the necessary packages. Since we’re using SQL Server for our example, we’ll need the following:
- Microsoft.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.Design
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.Tools
To generate the DbContext and entities from the database, open the "Package Manager Console" and run this command:
Scaffold-DbContext "Server=.;Database=AdventureWorks2017;Trusted_Connection=True;" Microsoft.EntityFrameworkCore.SqlServer -OutputDir Entities
Looking at the AdventureWorks2017Context class, we can already see some issues. The sample database is not particularly complex, but the OnModelCreating method has a somewhat ridiculous line count of 4,244 lines. This is going to make maintenance quite difficult.
In order to pare that number down a bit, let’s start over. Delete the "Entities" folder and run this command.
Scaffold-DbContext "Server=.;Database=AdventureWorks2017;Trusted_Connection=True;" Microsoft.EntityFrameworkCore.SqlServer -OutputDir Entities -DataAnnotations
The -DataAnnotations flag tells EF Core to use declarative configuration whenever possible. This means using attributes such as Table, Column, and Key rather than the more verbose "fluid configuration". Unfortunately, we’re still looking at 2,605 lines in OnModelCreating, but that’s still an improvement.
Reviewing EF Scaffolding Warnings and Errors
When the Scaffold-DbContext runs, it is not going to be able to process the whole database. In some cases, EF Core simply doesn’t support a given database feature. In other cases, there is a design mismatch between the ORM and the database. With EF Core 3.1 and AdventureWorks2017, there are 18 notifications which we can group into 4 categories.
Unsupported Types
EF Core does not currently support CLR Types such as hierarchyid. This issue is being tracked in bug report #365, which dates back to June of 2014. User Defined Types are not supported in EF Core in general (see bug report #12796).
If the primary key for a table is an unsupported type, you may see a generic error message such as "Unable to generate entity type for table 'Production.Document'."
Partially Supported Types
Some data types are only supported via third parties. For example, spatial data types (geometry, geography) require NetTopologySuite. This package isn’t honored by Scaffold-DbContext so you’ll have to manually configure those columns.
Default Constraints
In Entity Framework, there is no way to ‘skip’ columns when performing an insert. Every column must have a value, even if that value is null. For a nullable field, this is no problem. One simply skips the null fields and the database provides the actual default.
For non-nullable fields, things become trickier. If you pass in a non-null value, then the database will ignore the default. You can’t pass in a null if the property isn’t nullable. And as we mentioned above, you can’t simply skip the field.
For EF Core the solution is to map the non-nullable field to a nullable property. Theoretically this will only be null for a new record that has never been saved. But defensive coding practices require null checks anyways so this will complicate code using our DAL.
Indexes
Some indexes do not have EF Core representations because they use unsupported types. This is only an issue if you allow Entity Framework to modify your database schema. Otherwise none of the index mappings are relevant.
Fixing Column Names
Before we move on, we should follow industry best practices by turning on "treat warnings as errors". This will ensure potential problems are not ignored.
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
Now we’ll have to deal with a minor difference in how columns/properties are named. In SQL Server, column names can be pretty much anything. But in C#, you normally don’t use property names beginning with a number. This is a problem for the class VSalesPersonSalesByFiscalYears.
The Scaffold-DbContext tool solves this by prepending the property names with an underscore. A Column attribute maps it back to the underlying table or view.
[Column("2002", TypeName = "money")]
public decimal? _2002 { get; set; }
[Column("2003", TypeName = "money")]
public decimal? _2003 { get; set; }
[Column("2004", TypeName = "money")]
public decimal? _2004 { get; set; }
This will trigger CA1707: Identifiers should not contain underscores, which we can easily solve by changing the property names.
[Column("2002", TypeName = "money")]
public decimal? Year2002 { get; set; }
[Column("2003", TypeName = "money")]
public decimal? Year2003 { get; set; }
[Column("2004", TypeName = "money")]
public decimal? Year2004 { get; set; }
Enabling Nullable Reference Type Support
For our next step, we’ll turn on nullable reference types in the project file. And to ensure that those errors aren’t hidden, we’ll also enable "warnings as errors".
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
</PropertyGroup>
This produces a whopping 573 errors. If that’s too much to deal with, you can undo the change and instead turn on null checking on a file-by-file basis. For example, open "AdventureWorks2017Context.cs" and put this line at the top of the file:
#nullable enable
Now it should only report 178 errors, all of them being in AdventureWorks2017Context. But for the rest of the article, we’ll assume you left the project level setting enabled.
DBSet<TEntity>
All of the DBSet<TEntity> properties will be populated by the DBContext itself in the constructor. So, the warnings about them being potentially null can be ignored. This can be done via the #nullable compiler directive.
#nullable disable
public virtual DbSet<Address> Address { get; set; }
public virtual DbSet<AddressType> AddressType { get; set; }
public virtual DbSet<WorkOrderRouting> WorkOrderRouting { get; set; }
#nullable restore
Note that at the end of the block we use #nullable restore rather than #nullable enable. This ensures the project-level setting is honored for the rest of the file.
String Properties and Byte arrays.
Since string properties are value-types, they will default to null for newly created objects. So, you’ll have to indicate nullability by globally replacing all instances of "public string" with "public string?".
Likewise, you’ll need to make the byte arrays (byte[]) as nullable.
Scalar Object Properties
Scalar object properties refer to a single parent or child object. It may be a foreign key or the reciprocal of the same.
[ForeignKey(nameof(BusinessEntityId))]
[InverseProperty("Vendor")]
public virtual BusinessEntity BusinessEntity { get; set; }
[InverseProperty("BusinessEntity")]
public virtual Vendor Vendor { get; set; }
These will initially be null and so should be marked as such just like string properties. The only difference is there isn’t a convenient search-and-replace pattern.
Warning: If you have a constructor in the entity, then the error will be placed on the constructor rather than the property itself.
Collection Properties
You may also see collection properties. These do not need to be marked as nullable because the constructor will populate them for you.
[InverseProperty("Vendor")]
public virtual ICollection<PurchaseOrderHeader> PurchaseOrderHeader { get; set; }
WithOne/WithMany
Once you have made all of the scalar object properties nullable, the DbContext will start reporting errors again. Specifically, the WithOne
and WithMany clauses inside the OnModelCreating method will complain about dereferencing or returning nulls.
The problem here is that none of these clauses are actually executed. They just generate expression trees Entity Framework will later use for SQL generation. Thus, the nulls aren’t really an issue.
You can override the behavior by using the bang operator ! as shown below.
entity.HasOne(d => d.StateProvince)
.WithMany(p => p!.Address)
.HasForeignKey(d => d.StateProvinceId)
.OnDelete(DeleteBehavior.ClientSetNull);
But given the number of occurrences, it may be better to just use #nullable disable for the entire OnModelCreating method. Make sure you also use #nullable restore to ensure anything else you add to the DbContext will still be checked for null issues.
Enabling Code Analysis
Now we turn to static analysis. To enable it, add a reference to the Microsoft.CodeAnalysis.FxCopAnalyzers NuGet package.
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8"/>
This will produce roughly 80 compiler errors, which we’ll address by type.
CA2227: Collection properties should be read only
Rule CA2227 is designed to prevent problems caused by replacing collections in an object. When a collection is replaced it may cause the parent object to lose its event handlers or other internal state based on the collection. It may also cause cross-linking, where two objects unintentionally treat the same collection as their exclusive child.
EF Core does not actually expect collection properties to be mutable. (The collection is mutable, just not the property itself.) Under normal circumstances this is not required so you can change the property to be read-only.
[InverseProperty("Vendor")]
public virtual ICollection<PurchaseOrderHeader> PurchaseOrderHeader { get; }
You may have noticed that the collection is also marked as virtual. This is only necessary when using lazy-loading via the Microsoft.EntityFrameworkCore.Proxies package. Since lazy-loading is frowned upon in professional development, you can probably remove the virtual marker as well.
For your convenience, here are some regular expressions to use in a global search-and-replace.
Find: public virtual ICollection<(.*)> (.*) { get; set; }
Replace: public ICollection<$1> $2 { get; }
CA1819: Properties should not return arrays
Rule CA1819 should be ignored in any entity property. There’s no way around it; if you want to store a byte array in the database then you need a byte array property.
The easiest way to suppress the error is to adorn the property with a suppression attribute.
[SuppressMessage("Performance", "CA1819")]
public byte[]? LargePhoto { get; set; }
CA1056: URI properties should not be strings
Likewise, rule CA1056 should be ignored. EF Core does not currently support URI as an alternative to strings for mapping properties.
CA1720: Identifiers should not contain type names
The naming rule CA1720 was designed to ensure that APIs don’t use language-specific type names. For example, you should use Int32 rather than C#’s int or VB’s integer. This was especially important in the early days of .NET when Microsoft actively supported four languages in the platform. Even better, when possible you shouldn’t use a type name at all and instead use a more descriptive term.
In our case this rule should be suppressed, as it is just coincidence that the database column name happens to also be a type name.
CA1062: Validate arguments of public methods
Rule CA1062 should never be ignored. It ensures you get meaningful error messages instead of generic null-reference exceptions. It also makes it clear that the error was caused by the calling code, not the method itself.
For more information on this concept, see Designing with Exceptions in .NET.
CA1303: Do not pass literals as localized parameters
If you use a refactoring tool to solve CA1062, you are likely to end up with something that looks like this:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
if (modelBuilder == null)
throw new ArgumentNullException(nameof(modelBuilder), $"{nameof(modelBuilder)} is null.");
And now you have a CA1303 error. This rule is designed for library developers that support multiple languages. It is trying to remind you to internationalize your error messages using a resource dictionary.
For most applications this isn’t appropriate and you can disable the rule entirely. If you use Visual Studio’s ctrl+. menu to do this, it will create an ".editorconfig" file at the solution level with the following line:
dotnet_diagnostic.CA1303.severity = none
It is referenced in the project file as:
<ItemGroup>
<None Include="..\.editorconfig" Link=".editorconfig" />
</ItemGroup>
If you want it to only apply to a single project, you can move the file.
Conclusion
Enabling nullable reference types and code analysis on Entity Framework code is not difficult and can usually be performed in an afternoon. The only real problem is whenever you regenerate the DAL from the database, you’ll have to reapply the changes you previously made.
A complete listing of the before and after code is available in the EFCoreNullable project on GitHub.
New projects should use Nullable: Enable from day one.
All projects should use TreatWarningsAsErrors and FxCopAnalyzers.
Don’t hesitate to disable static analysis rules that aren’t beneficial to your project.
Fix issues incrementally so that you aren’t overwhelmed.
Regenerating EF Core entities may require reapplying changes, so carefully review diffs before committing code.
About the Author
Jonathan Allen got his start working on MIS projects for a health clinic in the late 90's, bringing them up from Access and Excel to an enterprise solution by degrees. After spending five years writing automated trading systems for the financial sector, he became a consultant on a variety of projects including the UI for a robotic warehouse, the middle tier for cancer research software, and the big data needs of a major real estate insurance company. In his free time he enjoys studying and writing about martial arts from the 16th century.