Code First without pain: migrations that do not break production

Code First is convenient while the model, migration and production database move together. Learn to design entities, indexes, migrations, Azure deployment and verification so change stays predictable.

The full article is available below. Enable JavaScript to use quizzes and simulations.

A production lesson • EF Core • Azure • SQL

Code First works best when a migration is a decision, not an accident.

Code First is convenient but quickly becomes dangerous if the database is treated as an automatic side effect of a C# model. This article follows the process: model, migration, indexes, deployment, verification console and post-deployment acceptance.

model migration Azure acceptance
01 An entity is not a form. It is a database contract.
02 A migration needs a plan, a test and a way back.
03 After deployment, keep evidence: logs, health, queries and results.

Trouble often starts innocently. Someone adds a feature: customer reviews, moderation status, event history, payments or an admin flag. Everything works locally. The form saves data, the list refreshes and a button publishes a post. Production reveals whether we built a system or just a form attached to a table.

In a well-run project, a database is not somewhere data “somehow gets saved”. It is an agreement between code, users, administration, backups, privacy and future changes. Code First can be excellent, provided it does not replace thinking about data.

arccloud migration lab

$ dotnet ef migrations add CustomerReviews

✓ model snapshot updated

$ dotnet ef migrations script --idempotent -o db.sql

✓ idempotent script generated

$ az webapp deployment slot swap --slot staging

• smoke tests running: api, admin, public blog

✓ release accepted ▌

Scenario: a small module that touches production

Suppose we add customer reviews. A user submits a name, company, rating and comment. The review is moderated before appearing publicly. Email is only for verification and must remain private. Administrators need to approve or reject submissions.

At first glance, these are just a few fields. In practice, they require decisions about privacy, indexes, states, spam prevention, validation, administrative flow and behavior six months from now.

Poor approach

Build a table mirroring the form, with email, text and rating. Publication is a boolean. Weeks later, moderation, review sources, spam prevention and change history are added. The model grows without a plan.

Better approach

Model the process: pending, approved, rejected. Keep email private or store a hash. Index the queries actually used by the website and panel. Describe and test the migration before release.

The C# model: a sensible minimum

An entity should express the system's durable truth. It need not know how a form looks. It needs rules that remain meaningful even when the UI changes.

public sealed class CustomerReview
{
    public Guid Id { get; set; }
    public string DisplayName { get; set; } = null!;
    public string? CompanyName { get; set; }
    public string EmailHash { get; set; } = null!;
    public int Rating { get; set; }
    public string Body { get; set; } = null!;
    public ReviewStatus Status { get; set; } = ReviewStatus.Pending;
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
    public DateTimeOffset? PublishedAt { get; set; }
}

public enum ReviewStatus
{
    Pending = 0,
    Approved = 1,
    Rejected = 2
}

The key point is that this model does not publish private email addresses. If email is needed for verification, keep it outside the public model or store a hash for deduplication. The website needs the review, public author name, company and rating. Administration needs the source, history and decision.

Why is a status better than just Published = true?

A real process has more than two states. A review may be pending, approved, rejected, returned for correction or hidden later. A boolean lasts only until the first exception. A status lets the system grow without accumulating chaotic flags.

EF Core configuration: limits, indexes and intent

If EF configuration feels like a formality, nobody has probably asked how this table will be queried in production.

builder.Entity<CustomerReview>(e =>
{
    e.Property(x => x.DisplayName)
        .HasMaxLength(120)
        .IsRequired();

    e.Property(x => x.CompanyName)
        .HasMaxLength(160);

    e.Property(x => x.EmailHash)
        .HasMaxLength(96)
        .IsRequired();

    e.Property(x => x.Body)
        .HasMaxLength(1800)
        .IsRequired();

    e.Property(x => x.Rating)
        .IsRequired();

    e.HasIndex(x => new { x.Status, x.PublishedAt });
    e.HasIndex(x => x.EmailHash);
});

The index Status + PublishedAt makes sense because the public website fetches only approved reviews, usually ordered by publication date. An index on EmailHash helps moderation and abuse prevention. These are indexes for known system paths, not hypothetical future needs.

Formvalidation + consent
APIrate limit + hash
Databasestatus + index
Admindecision + audit trail
Publicapproved only

Migration: a script you can read

A migration should not be a black box. If the team does not read the generated script, it does not really know what will reach the database.

migrationBuilder.CreateTable(
    name: "CustomerReviews",
    columns: table => new
    {
        Id = table.Column<Guid>(nullable: false),
        DisplayName = table.Column<string>(maxLength: 120, nullable: false),
        CompanyName = table.Column<string>(maxLength: 160, nullable: true),
        EmailHash = table.Column<string>(maxLength: 96, nullable: false),
        Rating = table.Column<int>(nullable: false),
        Body = table.Column<string>(maxLength: 1800, nullable: false),
        Status = table.Column<int>(nullable: false),
        CreatedAt = table.Column<DateTimeOffset>(nullable: false),
        PublishedAt = table.Column<DateTimeOffset>(nullable: true)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_CustomerReviews", x => x.Id);
    });

migrationBuilder.CreateIndex(
    name: "IX_CustomerReviews_Status_PublishedAt",
    table: "CustomerReviews",
    columns: new[] { "Status", "PublishedAt" });

A new table is relatively safe. Existing-table changes are harder: adding a NOT NULLcolumn, rebuilding an index, changing a type, backfilling data or removing an old field. Plan locking and execution time.

What do I check before migrating an existing table?
  • whether a column has a default and could force a full lock on a large table,
  • whether the backfill can run in batches,
  • whether new code is compatible with both old and new structures,
  • whether rollback means reverting code, data or both,
  • whether we have a backup and a restore test, not merely a promise of backups.

Azure: migration as an explicit step, not startup magic

Automatic startup migrations are convenient but can be risky in production. A heavy migration can delay startup, time out or block several instances. Simple changes can be controlled this way; critical database changes are better handled as an explicit step.

dotnet ef migrations script --idempotent -o ./artifacts/db.sql

az webapp deployment slot swap \
  --name arccloud-api \
  --resource-group prod \
  --slot staging

psql "$DATABASE_URL" \
  -v ON_ERROR_STOP=1 \
  -f ./artifacts/db.sql

curl -fsS https://www.arccloudstudio.com/health

There is no requirement to follow this exact procedure every time. The principle is that migration should be visible, repeatable and verifiable. If it fails, you need to identify whether the problem is the schema, data, application, routing or configuration.

Database deployment runbook

  1. Preflight: check the backup, connection and application version.
  2. Script: generate idempotent SQL and read it before execution.
  3. Apply: run the migration as a separate step that stops on error.
  4. Smoke: check the endpoint, writes, reads and administration panel.
  5. Closeout: record evidence: logs, time, outcome and database changes.

A small debugging console: what really happens after deployment?

After deployment, I ask “what proves it works?” rather than simply “does it work?” The difference is substantial. Below is a minimal acceptance example following a model change.

SELECT "Status", count(*)
FROM "CustomerReviews"
GROUP BY "Status";

SELECT indexname
FROM pg_indexes
WHERE tablename = 'CustomerReviews';

SELECT "DisplayName", "Rating", "PublishedAt"
FROM "CustomerReviews"
WHERE "Status" = 1
ORDER BY "PublishedAt" DESC
LIMIT 5;

SQL Status counts: pending=3, approved=0, rejected=0

IDX IX_CustomerReviews_Status_PublishedAt found

API public endpoint returns only approved reviews

ADMIN moderation action creates audit trail

A common mistake: the model grows faster than responsibility

The worst Code First projects let the C# model grow faster than ownership of the data. Six months later, the database has columns without clear meanings, unexplained indexes, undocumented migrations and records nobody can safely correct.

Good Code First does the opposite. Code provides speed, but the database remains a production contract. Every change answers: who writes this, who reads it, how do we filter it, what is private, how do we undo it and how do we verify it after deployment?

Pre-migration checklist

  • Model: the entity describes the process, not just form fields.
  • Limits: text, names and statuses have practical constraints.
  • Indexes: follow queries used by the public website or administration panel.
  • Privacy: email and sensitive data stay out of the public API.
  • Deployment: the migration is explicit, logged and repeatable.
  • Acceptance: post-deployment evidence exists beyond someone saying “it went through”.

A short test for your project

Choose any application table and answer without looking at the code:

Which three endpoints read it most often? Which data is private and must not reach the frontend? Which index supports its most important list? What must be checked after a migration?

If the answers are not obvious, that does not mean the project is bad. It means the database is asking for structure before it becomes an expensive problem.

Preparing a database change for production?

Let us review the model, migration and risks before users feel a defect.

Describe the module, table or problem. I will return with a concrete sequence: what to inspect, what to improve and how to accept the deployment without guesswork.

Tell us about it