Refactoring When Data and Contracts Change: Renaming an API Field, an EF Core Property and an Angular Model Together
When a property is renamed, the external contracts (JSON field, database column, CSV header, cached shape) must be separated from the code and moved step by step with expand-contract. The post renames Book.Price to ListPrice and moves DiscountRate to its own table across .NET, EF Core, SQL Server and Angular, showing with real output a broken view, a cache read as zero, a tolerant reader and a script that bypassed the dual write.
Version note: The C# code in this post was built and run with C# 14 and .NET 10 (SDK 10.0.402) on Entity Framework Core 10.0.15 and SQL Server 2025, with tests on xUnit.net v3 4.0.3; the Angular code was built and run with Angular 22.2, TypeScript 6.0 (Angular 22.2 projects require TypeScript 6.0) and Vitest 5.1.
Guess First: "Just a Rename"
At Bookstore, a book's price has been Book.Price for years. When campaign prices arrived, the team started mixing up "list price" and "sale price", and a decision was made: the property will be called ListPrice. The catalog API's Book entity is simple, and the endpoint returns the entity as JSON as it is:
public class Book
{
public int Id { get; set; }
public required string Title { get; set; }
public decimal Price { get; set; }
public decimal DiscountRate { get; set; }
}
A rename in the IDE (F2) updates every reference. The diff is small and the build is green:
$ git diff --stat
src/Bookstore.Catalog/Book.cs | 2 +-
src/Bookstore.Catalog/CatalogDbContext.cs | 2 +-
src/Bookstore.Catalog/Program.cs | 2 +-
tests/Bookstore.Catalog.Tests/CatalogApp.cs | 4 ++--
4 files changed, 5 insertions(+), 5 deletions(-)
$ dotnet build
Build succeeded.
The compiler says nothing, because every place the compiler can see was updated. The question is: what did this rename change in the places the compiler can't see? Make a list before you read on.
The Answer: Four Contracts the Compiler Doesn't See
The first is the database. Entity Framework Core derives column names from property names, so the new migration produces a column rename:
$ dotnet ef migrations add RenameBookPrice
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Price",
table: "Books",
newName: "ListPrice");
}
The moment this migration is applied, every instance running the old version looks for the Price column and can't find it; during a rolling deploy, two versions are connected to the same database for a few minutes. There are readers outside the application too. The finance team's reporting tool has been reading from a view they wrote themselves for years, and after the migration the view answers like this:
Msg 207, Level 16, State 1, Server b8be943222fa, Procedure vw_BookNetPrices, Line 1
Invalid column name 'Price'.
Msg 4413, Level 16, State 1, Server b8be943222fa, Line 1
Could not use view or function 'dbo.vw_BookNetPrices' because of binding errors.
The second is the API. There was a contract test pinning the catalog service's JSON; the Angular client reads the price field:
failed Bookstore.Catalog.Tests.ContractTests.Book_json_keeps_its_field_names (139ms)
Assert.Equal() Failure: Strings differ
↓ (pos 24)
Expected: "{"id":1,"title":"Dune","price":450.00,"discountRat"···
Actual: "{"id":1,"title":"Dune","listPrice":450.00,"discoun"···
The third is the exported file. The header of the CSV file that finance loads into Excel every month was generated from property names with reflection; it now says Id;Title;ListPrice;DiscountRate, and the macro that looks the column up by name comes back empty.
The fourth is the quietest: the cache. The endpoint writes the book as JSON to a shared distributed cache. At deploy time the cache still holds entries written by the old version. A small program that reads an entry written by the old version into the new Book type:
// Written to the shared cache by the old version, before the deploy.
string cached = """{"Id":1,"Title":"Dune","Price":450.00,"DiscountRate":0.10}""";
Book book = JsonSerializer.Deserialize<Book>(cached)!;
Console.WriteLine($"{book.Title}: ListPrice={book.ListPrice}, DiscountRate={book.DiscountRate}");
Dune: ListPrice=0, DiscountRate=0.10
No exception, no log; until the cache entry expires, Dune's price is zero. By default System.Text.Json silently skips the Price field it doesn't recognize. We reverted the whole change (dotnet ef database update Initial, then git restore) and did the work again, separating the contracts one by one.
Separating the Name in Code from the External Contract
In the first step only the name inside the code changes. The names the outside world sees are written explicitly: the JSON field with [JsonPropertyName], the column with HasColumnName.
public class Book
{
public int Id { get; set; }
public required string Title { get; set; }
// The name changed in code only; the JSON field and the column are still "price"/"Price".
[JsonPropertyName("price")]
public decimal ListPrice { get; set; }
public decimal DiscountRate { get; set; }
}
// CatalogDbContext.OnModelCreating
book.Property(b => b.ListPrice).HasColumnName("Price").HasPrecision(10, 2);
A command that came with EF Core 8 confirms that the model really didn't change on the database side:
$ dotnet ef migrations has-pending-model-changes
No changes have been made to the model since the last migration.
For the CSV, the fix is to stop deriving the header from property names. Finance's file is a contract too; the columns are written explicitly:
// The finance team's file is a contract too: its header is written explicitly, not taken from property names.
public static string WriteBooks(IEnumerable<Book> books) => Write(books,
("Id", b => b.Id),
("Title", b => b.Title),
("Price", b => b.ListPrice),
("DiscountRate", b => b.DiscountRate));
The cache, however, had one more surprise. With this version too, the same small program printed ListPrice=0. The code that writes to the cache calls JsonSerializer with default options, and the field name in the old entries is Price; the attribute now says price. Reading with default options is case-sensitive, so the field still doesn't match. The web defaults ASP.NET Core uses for the API are case-insensitive, which is why the difference shows up only in the cache. The fix is to add a version to the key every time the cached shape changes: book:v2:{id}. Old entries are never read again and expire on their own. We pinned this with a test that writes an entry in the old shape to the book:1 key and checks that the API returns the right price.
Running Expand-Contract in Code: The API Field
In code we say ListPrice, but the API still says price. If we also want to rename the API field, the way to do it without breaking clients is expand-contract: first publish both (expand), move the clients, then remove the old one (contract). Danilo Sato's 2014 ParallelChange article describes the same three steps; the technique we used for a method signature in the third post is applied here to a contract. For this we also stopped returning the entity directly; the API's shape is now a separate record:
public sealed record BookResponse(int Id, string Title, decimal ListPrice, decimal DiscountRate)
{
// Expand: clients that still read "price" keep working. Removed in the contract step.
public decimal Price => ListPrice;
public static BookResponse From(Book book) => new(book.Id, book.Title, book.ListPrice, book.DiscountRate);
}
// Tolerant reader: old clients send "price", new clients send "listPrice".
public sealed record PriceChange(decimal? ListPrice, decimal? Price)
{
public bool UsesLegacyField => ListPrice is null && Price is not null;
public decimal? Value => ListPrice ?? Price;
}
In the expand version, GET /books/1 returns this JSON: {"id":1,"title":"Dune","listPrice":450.00,"discountRate":0.10,"price":450.00}. The price change endpoint accepts both fields and logs the User-Agent when the old field arrives. This is the idea in Fowler's 2011 TolerantReader article: the reader looks only at the fields it needs and stays flexible about fields it doesn't know or that are missing. Not a guess but this log decides when the contract step happens; once no client used the old field for a few days, Price was removed from BookResponse and PriceChange. That commit's title is not refactor: but feat!:, because a contract visible from the outside changes. The cache key moved up a version again too.
Bringing the Angular Client into the Migration
On the Angular side, when we change the field in the Book interface to listPrice, template type checking is on, so the old usage in the template is caught at build time:
✘ [ERROR] TS2339: Property 'price' does not exist on type 'Book'. [plugin angular-compiler]
src/app/catalog/book-price.ts:9:34:
9 │ ...<span class="price">{{ book().price * (1 - book().discountRate) ...
After fixing the template, the build is green. But the real risk is where the compiler can't see: http.get<Book>(...) is not a type conversion, it is a promise made to the compiler. If the API still sends only price (because the expand version hasn't been deployed yet, or the API was rolled back), the listPrice field doesn't exist at runtime. A test that mimics the old API's response with HttpTestingController shows it:
FAIL |bookstore-web| src/app/catalog/book-api.spec.ts > BookApi > reads a book from an API that still sends only "price"
AssertionError: expected undefined to be 450 // Object.is equality
In the template this means NaN on the screen. We build a tolerant reader on the client side too: the shape that comes over HTTP (BookDto) and the shape the app uses (Book) become separate types, and the mapping between them carries the migration:
// What travels over HTTP while the API is being migrated: old API versions send only "price".
export interface BookDto {
id: number;
title: string;
listPrice?: number;
price?: number;
discountRate: number;
}
export function toBook(dto: BookDto): Book {
const listPrice = dto.listPrice ?? dto.price;
if (listPrice === undefined) {
throw new Error(`Book ${dto.id} has neither listPrice nor price.`);
}
return { id: dto.id, title: dto.title, listPrice, discountRate: dto.discountRate };
}
The service now does http.get<BookDto>(...).pipe(map(toBook)) and sends { listPrice } on a price change. The tests cover all three cases: only price, both, and writing with the new field. The order matters: the Angular app has to be deployed after the API's expand version, because it sends the new field when writing; when reading, it works with both versions. The API's contract step comes last. The price field in BookDto is also a migration leftover waiting to be deleted once the API contract step is done. The wider modernization on the template and signal side is the topic of the next post.
Moving Data: DiscountRate to Its Own Table
The second change isn't a rename but data moving to a new place: discounts will grow with dates and campaign details, so the Books.DiscountRate column moves to a BookDiscounts table. Expand-contract on the code side is four steps. In the first step the table is added and the existing data is copied; EF Core generates the migration and we add the backfill:
// Backfill: copy existing discounts into the new table.
migrationBuilder.Sql("""
INSERT INTO dbo.BookDiscounts (BookId, Rate)
SELECT Id, DiscountRate FROM dbo.Books WHERE DiscountRate <> 0;
""");
In the same version, the code that writes writes to both places; the code that reads still reads from the old column:
// Dual write: the old column and the new table stay equal until the old column is dropped.
public void SetDiscount(decimal rate)
{
DiscountRate = rate;
if (Discount is null)
Discount = new BookDiscount { BookId = Id, Rate = rate };
else
Discount.Rate = rate;
}
Before moving the readers we checked with a query that the two sources really are equal. On the first run there were zero differences. The next day the same query found one:
SELECT COUNT(*) AS Mismatches
FROM dbo.Books b LEFT JOIN dbo.BookDiscounts d ON d.BookId = b.Id
WHERE b.DiscountRate <> ISNULL(d.Rate, 0);
Mismatches
----------
1
The marketing team's campaign script was writing discounts directly with UPDATE dbo.Books SET DiscountRate = .... Dual write only covers the application's code; everyone who writes to the data from outside the application has to be found separately. We pointed the script at the API and closed the gap with a MERGE; the query returned 0 again.
In the third step the readers moved: BookResponse.From and the CSV export read the discount from book.Discount?.Rate ?? 0m. In this commit the contract test turned red once more:
failed Bookstore.Catalog.Tests.ContractTests.Book_json_has_only_listPrice_after_the_migration (155ms)
Expected: ···"le":"Dune","listPrice":450.00,"discountRate":0.10}"
Actual: ···"le":"Dune","listPrice":450.00,"discountRate":0}"
The application code was right; the code preparing the test data was setting DiscountRate directly, and the backfill in the migration had run on the test database while the table was still empty. The test seed is a writer too. Once the seed wrote through SetDiscount, it was green. The fourth step is the contract: the DiscountRate property was deleted and EF Core generated migrationBuilder.DropColumn(name: "DiscountRate", table: "Books"). This migration runs in a deploy after the old version that reads the column is completely gone. Finance's view depended on the column as well; the view was first changed to join BookDiscounts, and the column was dropped afterwards. Which deploy a migration runs in and the rules for backward-compatible schemas are covered in the Deployment Strategies series post "Where Migrations Fit in a Deploy: Expand-Contract, EF Core Migration Bundles and Backward-Compatible Schemas"; the same idea for contracts between services is in the Microservices series post "Service Contracts and Versioning: Expand-Contract, Tolerant Reader and Consumer-Driven Contract Tests".
The Whole Migration, Commit by Commit
$ git log --oneline
33bbf6d refactor!: contract discounts: drop Books.DiscountRate (deploy after the old version is gone)
feaa3a3 refactor: read discounts from BookDiscounts (API and CSV); test seed writes through SetDiscount
b627c42 feat: expand discounts: BookDiscounts table with backfill, discount changes write both
47346d0 feat: PUT /books/{id}/discount
4f1476e feat!: contract the book API: price removed from responses and price changes
7a37197 feat: expand the book contract: responses carry listPrice and price, price changes accept both
8059a94 refactor: rename Book.Price to ListPrice in code only; JSON field, column and CSV header unchanged, cache key v2
17be803 legacy: catalog API with Book.Price and Book.DiscountRate
The opening "just a rename" was a five-line diff; the safe version of the same intent is seven commits, two commits on the Angular side and at least three separate deploys. The difference is that no client, report or cache breaks at any step. The ! in the commit titles tells the reviewer and the release notes that a contract visible from the outside has changed.
Common Pitfalls
Forgetting the cache key. When the shape of a serialized object changes, old entries don't throw exceptions; they quietly produce wrong values. We saw it twice in this post: once the property name changed, once only the letter case set by an attribute did. Every commit that changes a cached shape should also change the key's version.
Not counting reports and views as part of the application. BI tools connected to the database, other teams' views and nightly SQL jobs don't see migrations. Before a column's name or location changes, list the views, procedures and functions that depend on the table with sys.dm_sql_referencing_entities('dbo.Books', 'OBJECT') and talk to their owners; in our database this query shows vw_BookNetPrices right away.
Tying exported files to property names. CSV or Excel headers generated with reflection change with every rename. The columns of a file someone else reads should be written explicitly and pinned with a test.
Assuming dual write is limited to application code. The campaign script and the test seed made the same mistake in different places. Run the query that compares the two sources before the readers move and regularly for a while after.
Doing the contract step by the calendar. Instead of removing the old field "two sprints later", measure its use with a log and remove it when it reaches zero. If there are clients whose updates depend on the user, like a mobile app, this can take months.
A Small Experiment
★☆☆ Find an endpoint in your own project that returns an entity directly as JSON. Rename a property and look at what dotnet ef migrations add generates and how the API output changes. Then do the same rename with HasColumnName and [JsonPropertyName] and see what dotnet ef migrations has-pending-model-changes says.
★★☆ Find code that writes objects to a shared cache. Write a test that reads JSON in the old shape into the new type and look at the result. Then add a version to the cache keys and confirm that the same test no longer reads the old entry at all.
★★★ Plan moving a column to a separate table with expand-contract: the migrations, the dual write, the query comparing the two sources, moving the readers and the migration that drops the column. Write down which deploy each step runs in and list everyone who writes to the database from outside the application.
In this post we changed only a model and a service on the Angular side. In the next post we modernize the Angular code itself: we'll move the old CheckoutComponent, which depends on an NgModule and uses *ngIf and nested subscribe calls, to standalone, inject() and signals with Angular's official migration schematics, review the output of each schematic, and convert by hand the part the schematics can't do.