In this article, we’ll explore how to implement Backing Fields and Shadow Properties in Entity Framework Core (EF Core), enhancing your control over data and metadata management. We will also walk through how to seed data into your database, handle migrations, and test both valid and invalid cases for modifying inventory quantities. By the end, you will have a robust project structure that ensures both data integrity and efficient metadata tracking.
1. Backing Fields in EF Core
Backing Fields are private fields that store data for public properties, giving you fine-grained control over how sensitive data is accessed and modified. This is especially useful for properties like Quantity
in an Inventory
class, where we need to enforce rules, such as preventing negative quantities.
Inventory Class with Backing Field for Quantity
Here’s how you can apply a backing field for the Quantity
property:
public class Inventory
{
private int _quantity; // Backing field for Quantity
public int Id { get; set; }
public int ProductId { get; set; }
public Product Product { get; set; }
public int Quantity
{
get => _quantity;
set
{
if (value < 0)
throw new ArgumentException("Quantity cannot be negative.");
_quantity = value;
}
}
}
- The
_quantity
backing field ensures that theQuantity
property cannot be set to a negative value, protecting your business logic and data integrity.
2. Refactor into Services and Configuration
To maintain a clean and scalable architecture, we’ll refactor the business logic into a Services folder and manage entity configuration in an EntityConfiguration folder. This keeps your code organized and maintainable as your application grows.
Folder Structure
/Services
/ProductService.cs
/InventoryService.cs
/EntityConfiguration
/ProductConfiguration.cs
/InventoryConfiguration.cs
3. Product and Inventory Configuration with Data Seeding
We’ll use the HasData() method to seed initial data for both Product
and Inventory
entities. When the database is created, this data will be automatically inserted, making it easier to test the application.
ProductConfiguration Class
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.Property(p => p.Price).HasColumnType("decimal(18,2)");
builder.HasOne(p => p.Inventory)
.WithOne(i => i.Product)
.HasForeignKey<Inventory>(i => i.ProductId);
// Seed data for Products
// Seed data for Products
builder.HasData(
new Product { Id = 3, Name = "Tablet", Price = 299.99M, CategoryId = 1 },
new Product { Id = 4, Name = "Smartwatch", Price = 199.99M, CategoryId = 1 },
new Product { Id = 5, Name = "Desktop", Price = 1200.00M, CategoryId = 1 },
new Product { Id = 6, Name = "Monitor", Price = 300.00M, CategoryId = 1 }
);
}
}
InventoryConfiguration Class
public class InventoryConfiguration : IEntityTypeConfiguration<Inventory>
{
public void Configure(EntityTypeBuilder<Inventory> builder)
{
builder.Property(i => i.Quantity).IsRequired();
// Seed data for Inventory
builder.HasData(
new Inventory { Id = 1, ProductId = 1, Quantity = 50 },
new Inventory { Id = 2, ProductId = 2, Quantity = 100 }
);
}
}
By using HasData()
, we ensure that the database is seeded with initial values for Product
and Inventory
.
4. Running Migrations and Updating the Database
Before using the seeded data, you need to run migrations and update the database. This ensures that the schema and data are applied correctly.
Steps to Migrate and Update the Database
-
Apply Entity Configurations: Ensure that
InventoryConfiguration
is applied in theAppDbContext
by adding this line:
modelBuilder.ApplyConfiguration(new InventoryConfiguration());
- Add Migrations: In the Package Manager Console or CLI, run the following command to add a migration:
Add-Migration SeedProductAndInventory
- Update the Database: Apply the migration and seed the data by running:
Update-Database
This will create the necessary database schema and insert the initial Product
and Inventory
data.
5. Testing the Backing Fields (Implemented in Sections 1-4)
Sure! To ensure we properly test the Backing Field logic in the InventoryService
, let’s first add the necessary logic in the InventoryService
class. Once that’s done, we’ll update the Program.cs
file to reflect the changes and test the functionality.
InventoryService with Backing Field Logic
We need to ensure the InventoryService
handles the validation logic based on the backing field defined in the Inventory
class. Specifically, the service should catch any exceptions triggered by trying to set a negative quantity.
Here’s how you can implement the logic in InventoryService
:
public class InventoryService
{
private readonly AppDbContext _context;
public InventoryService(AppDbContext context)
{
_context = context;
}
// Retrieve inventory by ProductId
public Inventory GetInventoryByProduct(int productId)
{
return _context.Inventories.FirstOrDefault(i => i.ProductId == productId);
}
// Update the quantity of inventory for a specific product
public void UpdateQuantity(int productId, int quantity)
{
var inventory = _context.Inventories.FirstOrDefault(i => i.ProductId == productId);
if (inventory != null)
{
try
{
inventory.Quantity = quantity; // Backing field logic will validate this
_context.SaveChanges();
}
catch (ArgumentException ex)
{
// Log the exception or throw a custom error if needed
throw new ArgumentException(ex.Message);
}
}
else
{
throw new InvalidOperationException("Inventory not found.");
}
}
}
Explanation:
-
GetInventoryByProduct: This method fetches the inventory for a given product based on
ProductId
. -
UpdateQuantity: It attempts to set a new quantity, which is validated by the backing field in the
Inventory
class. If the quantity is invalid (less than 0), anArgumentException
is caught and re-thrown.
Updating Program.cs
With the InventoryService
logic in place, we can now update Program.cs
to test both valid and invalid quantity updates.
using System;
class Program
{
static void Main(string[] args)
{
using (var context = new AppDbContext())
{
var productService = new ProductService(context);
// Create a product
productService.CreateProduct("Laptop", 999.99M, "Electronics");
// Read all products
var products = productService.GetAllProducts();
foreach (var product in products)
{
Console.WriteLine($"Product: {product.Name}, Category: {product.Category.Name}, Price: {product.Price}");
}
// Update product price
var productIdToUpdate = products[0].Id;
productService.UpdateProductPrice(productIdToUpdate, 899.99M);
}
CallInventory();
}
public static void CallInventory()
{
using (var context = new AppDbContext())
{
var inventoryService = new InventoryService(context);
// Access seeded inventory data for Product 3
var inventory = inventoryService.GetInventoryByProduct(3);
Console.WriteLine($"Product 3 has {inventory.Quantity} items in stock.");
// Update quantity with a valid value (greater than 0)
inventoryService.UpdateQuantity(3, 150);
Console.WriteLine("Quantity updated to 150.");
// Try updating with an invalid quantity (less than 0)
try
{
inventoryService.UpdateQuantity(3, -50);
}
catch (ArgumentException ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
}
}
Explanation:
- We start by retrieving the inventory for
ProductId = 3
and displaying its current quantity. - Next, we update the quantity with a valid value (
150
), and it will succeed. - Finally, we attempt to update the quantity with an invalid value (
-50
), which should trigger the backing field’s validation logic and throw an exception, which we catch and display.
With these changes, your InventoryService
now handles backing field validation, and you can test it in the Program.cs
file to ensure the logic behaves as expected.
6. Introducing Shadow Properties
Now that we’ve tested the backing field, let’s move on to Shadow Properties. Shadow properties exist in the EF Core model but are not explicitly defined in the entity classes. They are useful for tracking metadata like timestamps or additional information without modifying the domain model.
We’ll apply shadow properties to both the Product
and Inventory
entities to track when they were last updated.
InventoryConfiguration with Shadow Property
public class InventoryConfiguration : IEntityTypeConfiguration<Inventory>
{
public void Configure(EntityTypeBuilder<Inventory> builder)
{
builder.Property(i => i.Quantity).IsRequired();
// Add shadow property for tracking last updated time
builder.Property<DateTime>("LastUpdated");
// Seed data for Inventory
builder.HasData(
new Inventory { Id = 1, ProductId = 1, Quantity = 50 },
new Inventory { Id = 2, ProductId = 2, Quantity = 100 }
);
}
}
ProductConfiguration with Shadow Property
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.Property(p => p.Price).HasColumnType("decimal(18,2)");
// Add shadow property for tracking last updated time
builder.Property<DateTime>("LastUpdated");
builder.HasOne(p => p.Inventory)
.WithOne(i => i.Product)
.HasForeignKey<Inventory>(i => i.ProductId);
// Seed data for Products
builder.HasData(
new Product { Id = 1, Name = "Laptop", Price = 999.99M, CategoryId = 1 },
new Product { Id = 2, Name = "Smartphone", Price = 499.99M, CategoryId = 1 }
);
}
}
Add Migrations:
In the Package Manager Console or CLI, run the following command to add a migration:
Add-Migration AddLastUpdateFiled
Update the Database:
Apply the migration and seed the data by running:
Update-Database
7. Using Shadow Properties in InventoryService
To use the shadow property LastUpdated
, we’ll modify InventoryService
to update and retrieve the shadow property when inventory data is changed.
public class InventoryService
{
private readonly AppDbContext _context;
public InventoryService(AppDbContext context)
{
_context = context;
}
public void UpdateQuantity(int productId, int quantity)
{
var inventory = _context.Inventories.FirstOrDefault(i => i.ProductId == productId);
if (inventory != null)
{
inventory.Quantity = quantity;
// Update the LastUpdated shadow property
_context.Entry(inventory).Property("LastUpdated").CurrentValue = DateTime.Now;
_context.SaveChanges();
}
else
{
throw new InvalidOperationException("Inventory not found.");
}
}
public DateTime? GetLastUpdated(int productId)
{
var inventory = _context.Inventories.FirstOrDefault(i => i.ProductId == productId);
if (inventory != null)
{
// Retrieve the LastUpdated shadow property
return _context.Entry(inventory).Property("LastUpdated").CurrentValue as DateTime?;
}
return null;
}
}
8. Testing Shadow Properties in Program.cs
Let’s update Program.cs
to test the shadow properties in both the Product
and Inventory
entities.
using ProductData;
using ProductData.Services;
using System;
class Program
{
static void Main(string[] args)
{
using (var context = new AppDbContext())
{
var productService = new ProductService(context);
// Create a product
productService.CreateProduct("Laptop", 999.99M, "Electronics");
// Read all products
var products = productService.GetAllProducts();
foreach (var product in products)
{
Console.WriteLine($"Product: {product.Name}, Category: {product.Category.Name}, Price: {product.Price}");
}
// Update product price
var productIdToUpdate = products[0].Id;
productService.UpdateProductPrice(productIdToUpdate, 899.99M);
}
CallInventory();
}
public static void CallInventory()
{
using (var context = new AppDbContext())
{
var inventoryService = new InventoryService(context);
// Access seeded inventory data for Product 1
var inventory = inventoryService.GetInventoryByProduct(3);
Console.WriteLine($"Product 3 has {inventory.Quantity} items in stock.");
// Update quantity with a valid value (greater than 0)
inventoryService.UpdateQuantity(3, 150);
Console.WriteLine("Quantity updated to 150.");
var lastUpdated = inventoryService.GetLastUpdated(3);
Console.WriteLine($"Product 1 Last Updated: {lastUpdated}");
// Try updating with an invalid quantity (less than 0)
try
{
inventoryService.UpdateQuantity(3, -50);
}
catch (ArgumentException ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
}
}
Expected Output:
- You will see the updated timestamp for
ProductId = 3
, confirming that the shadow property is being updated correctly.
Conclusion
By testing the Backing Fields in section 5 and introducing Shadow Properties in section 6, we now have complete control over both data and metadata in our application. Backing fields ensure valid data input, while shadow properties provide metadata tracking without modifying the core domain models. With the implementation of both techniques, your EF Core solution is more robust and maintainable.
Source Code EFCoreDemo
Top comments (0)