Agile code, durable model: Agile, OOP and Code First in one flow

A practical lesson from acceptance criteria through object and relational models to an EF Core migration, tests and a safe release. Read, make decisions and see their consequences.

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

Workshop: from requirement to safe release

You are building one working flow, not learning three disconnected techniques.

Agile defines a small step and how to accept it. OOP protects rules within a model. Code First carries that model into the database. If one piece is weak, the result may look good in a pull request and still fail in production.

01Story and acceptance criteria
02Domain and relational models
03Migration, test and release

Agile, object-oriented programming and Code First are often described separately: Agile on a task board, OOP in a book about classes and Code First in Entity Framework Core documentation. In production, all three meet in one pull request.

We will build part of a work-management system: a project has sprints, a sprint has tasks, and a task can be assigned to a team member. Start with the behavior the user wants, then follow it to a safe migration.

1. Agility starts with boundaries

Our story sounds simple: as a project lead, I want to assign a task to a person in an active sprint so responsibility is clear and visible in the panel. The scope is not ready yet. Can completed tasks be reassigned? Must the person belong to the project? Who can make the change? What happens to two simultaneous requests?

01Active sprint

Assign a task only within an open work period.

02Same project

The person must be an active member of the task's project.

03Task state

A completed task must not quietly return to work.

04Idempotency

Repeating the same decision does not create another event.

05Audit

Record who assigned whom and when.

06Concurrency

A conflict produces a clear response, not lost data.

Decision 1 of 3

Where will you start implementation?

You have a story and an empty branch. Choose the first move.

With a WorkItems table

With criteria and behavior

With a POST endpoint

Choose an answer to see the consequence.

2. OOP: objects should protect decisions

An anemic model is a bag of data with public setters. Any handler can set arbitrary state and must remember the same validations. A richer model represents a business concept and protects valid transitions.

public sealed class WorkItem
{
    private WorkItem() { }

    public Guid Id { get; private set; }
    public Guid ProjectId { get; private set; }
    public Guid SprintId { get; private set; }
    public string Title { get; private set; } = string.Empty;
    public WorkItemStatus Status { get; private set; }
    public Guid? AssigneeId { get; private set; }
    public DateTimeOffset? AssignedAt { get; private set; }
    public Guid? AssignedBy { get; private set; }
    public uint Version { get; private set; }

    public Result AssignTo(
        TeamMember member,
        Sprint sprint,
        Guid actorId,
        DateTimeOffset now)
    {
        if (Status == WorkItemStatus.Done)
            return Result.Fail("A completed task cannot be reassigned.");

        if (sprint.ProjectId != ProjectId || !sprint.IsActiveAt(now))
            return Result.Fail("The task does not belong to an active sprint.");

        if (member.ProjectId != ProjectId || !member.IsActive)
            return Result.Fail("This person is not an active project member.");

        if (AssigneeId == member.Id)
            return Result.Ok();

        AssigneeId = member.Id;
        AssignedAt = now;
        AssignedBy = actorId;
        return Result.Ok();
    }
}

The private constructor exists for EF Core, private setters limit accidental mutation, and the AssignTo method names the intent. Notice idempotency: assigning the same person again succeeds without changing the timestamp or creating a new trail.

Why does the method accept Sprint and TeamMember?

The rule needs data from three objects. Passing collaborating entities is clear in a small model. In a larger system, a domain policy may fit when the rule does not naturally belong to one aggregate. Keep the decision together rather than scattering it across controller, repository and frontend.

3. Object modeling does not replace relational thinking

Code First does not mean writing classes and blindly trusting migrations. Code defines the model, but the database still has keys, index selectivity, locks, referential integrity and query costs.

Code First model: Project, Sprint, WorkItem, TeamMember and WorkItemAssignment history

Model map. Current state supports a fast operational screen, while separate history preserves evidence of each decision.

Projects1
have many
SprintsN
group
WorkItemsN
assigned to
TeamMembers0..1
TableRoleKey fieldsProtection
ProjectsWork boundaryId, Name, Statusunique name within the organization
SprintsExecution periodProjectId, StartAt, EndAtEndAt greater than StartAt
WorkItemsCurrent stateSprintId, Status, AssigneeId, VersionFKs, indexes, concurrency token
TeamMembersProject accessProjectId, UserId, IsActiveunique ProjectId + UserId pair
WorkItemAssignmentsDecision historyWorkItemId, AssigneeId, AssignedBy, AssignedAtappend-only

The current AssigneeId in WorkItems supports quick board reads. A separate history table provides a reliable audit. This is a deliberate trade-off between operational reads and decision history, not accidental duplication.

Decision 2 of 3

What should happen when a TeamMember is deleted?

Historical tasks must remain understandable.

Cascade Delete

Restrict and deactivation

No foreign key

Choose the database behavior.

4. Explicit EF Core configuration

public sealed class WorkItemConfiguration : IEntityTypeConfiguration<WorkItem>
{
    public void Configure(EntityTypeBuilder<WorkItem> builder)
    {
        builder.ToTable("WorkItems");
        builder.HasKey(x => x.Id);

        builder.Property(x => x.Title)
            .HasMaxLength(180)
            .IsRequired();

        builder.Property(x => x.Status)
            .HasConversion<string>()
            .HasMaxLength(24);

        builder.Property(x => x.Version)
            .IsRowVersion()
            .IsConcurrencyToken();

        builder.HasOne<TeamMember>()
            .WithMany()
            .HasForeignKey(x => x.AssigneeId)
            .OnDelete(DeleteBehavior.Restrict);

        builder.HasIndex(x => new { x.ProjectId, x.SprintId, x.Status });
        builder.HasIndex(x => new { x.ProjectId, x.AssigneeId });
    }
}

Specify lengths, enum conversion, indexes, concurrency and deletion behavior. The generated migration then becomes something to review, rather than a deployment surprise.

5. Safe migration: expand, migrate, contract

A common mistake is assuming the database and every application instance switch simultaneously. Rolling deployment may run two code versions for several minutes. The schema must tolerate that period.

Expand

Add nullable columns, history tables and indexes without breaking the old application.

Migrate

Introduce new writes and batched backfills. Measure missing data and errors.

Contract

Strengthen constraints and remove backward compatibility only after verification.

migrationBuilder.AddColumn<Guid>(
    name: "AssigneeId",
    table: "WorkItems",
    type: "uuid",
    nullable: true);

migrationBuilder.CreateIndex(
    name: "IX_WorkItems_ProjectId_AssigneeId",
    table: "WorkItems",
    columns: new[] { "ProjectId", "AssigneeId" });

Decision 3 of 3

You have 8 million tasks. When do you make a column NOT NULL?

Immediately in the first migration

After backfill and verification of zero missing values

Never

Choose when to strengthen the schema.

6. The application layer coordinates; the domain decides

The handler loads decision inputs, invokes domain behavior, saves history and handles infrastructure conflicts. It does not duplicate entity rules.

public async Task<Result> Handle(AssignWorkItem command, CancellationToken ct)
{
    var item = await db.WorkItems
        .SingleOrDefaultAsync(x => x.Id == command.WorkItemId, ct);

    var member = await db.TeamMembers
        .SingleOrDefaultAsync(x => x.Id == command.MemberId, ct);

    var sprint = item is null
        ? null
        : await db.Sprints.SingleOrDefaultAsync(x => x.Id == item.SprintId, ct);

    if (item is null || member is null || sprint is null)
        return Result.NotFound();

    var now = clock.UtcNow;
    var result = item.AssignTo(member, sprint, command.ActorId, now);
    if (result.IsFailure) return result;

    db.WorkItemAssignments.Add(
        WorkItemAssignment.From(item, command.ActorId, now));

    try
    {
        await db.SaveChangesAsync(ct);
        return Result.Ok();
    }
    catch (DbUpdateConcurrencyException)
    {
        return Result.Conflict(
            "The task has just changed. Refresh the view.");
    }
}

Concurrency is practical, not theoretical. Two people can open the same board. Without version tokens, the last write silently wins and the user does not know they overwrote another decision.

7. Design reads for the screen

Domain entities support decisions. A board needs flat data and a fast query, not a complete entity graph, lazy loading or serialized navigation properties.

var board = await db.WorkItems
    .AsNoTracking()
    .Where(x => x.ProjectId == projectId && x.SprintId == sprintId)
    .OrderBy(x => x.Status)
    .ThenBy(x => x.Title)
    .Select(x => new WorkItemBoardRow(
        x.Id,
        x.Title,
        x.Status,
        x.AssigneeId,
        x.AssigneeId == null
            ? null
            : db.TeamMembers
                .Where(m => m.Id == x.AssigneeId)
                .Select(m => m.DisplayName)
                .Single()))
    .ToListAsync(ct);

Projection limits columns, AsNoTracking removes tracking overhead, and the ProjectId + SprintId + Status index matches actual filtering. Reads and writes can have different models without forcing an elaborate CQRS architecture.

8. Tests protect decisions, not implementation details

[Fact]
public void AssignTo_rejects_member_from_another_project()
{
    var item = WorkItemTestFactory.Open(projectId: ProjectA.Id);
    var sprint = SprintTestFactory.Active(projectId: ProjectA.Id);
    var outsider = TeamMemberTestFactory.Active(projectId: ProjectB.Id);

    var result = item.AssignTo(outsider, sprint, Admin.Id, Now);

    result.IsFailure.Should().BeTrue();
    item.AssigneeId.Should().BeNull();
}

[Fact]
public void AssignTo_is_idempotent_for_the_same_member()
{
    var item = WorkItemTestFactory.Open(projectId: ProjectA.Id);
    var member = TeamMemberTestFactory.Active(projectId: ProjectA.Id);
    var sprint = SprintTestFactory.Active(projectId: ProjectA.Id);

    item.AssignTo(member, sprint, Admin.Id, Now);
    var second = item.AssignTo(member, sprint, Admin.Id, Now.AddMinutes(1));

    second.IsSuccess.Should().BeTrue();
    item.AssignedAt.Should().Be(Now);
}

The first test proves a rule cannot be bypassed. The second proves repeated requests do not alter history. Then use integration tests with real PostgreSQL: constraints, mapping and version conflicts should not be simulated by an in-memory provider.

Domainfast rule and state-transition tests
IntegrationEF Core, PostgreSQL, indexes, transactions
ContractHTTP status, validation, error format
E2Eone critical user journey

9. Definition of Done is an acceptance contract

Finished code is not necessarily a finished feature. Agile shortens feedback loops without removing quality. This checklist is short because every item is verifiable.

  • Acceptance criteria have domain or contract tests.
  • The migration was reviewed as SQL, not only C#.
  • Indexes match screen queries and administrative operations.
  • Logs contain task and correlation identifiers without private data.
  • Metrics distinguish success, business refusal and concurrency conflicts.
  • Release is controlled through a feature flag or gradual rollout.
  • Rolling back to the old application remains compatible with the new schema.
  • Documentation describes the decision and consequences, not merely a file list.

Interactive acceptance

Run the change through the decision pipeline

The animation shows check order. It models the process; it does not execute the article's code.

Run the checks

1Storyacceptance conditions
2Domainrules and state
3Schemaconstraints and indexes
4Testevidence of behavior
5Releasemetrics and rollback

Ready to run.

Five safe-release gates: acceptance criteria, OOP domain, Code First, tests and release

Acceptance path. Each gate provides verifiable evidence before the change proceeds.

10. Shortcuts that cost more later

ShortcutWhy it appealsWhat it breaksBetter decision
Public settersquick CRUDrules can be bypassedmethods that name intent
One huge migrationfewer stepslocks and no rollbackexpand–migrate–contract
Include the entire grapheasy mappingheavy queriesscreen-specific projection
Only unit teststhey are fastdo not verify SQL or constraintsdomain tests plus database integration
Definition of Done: “works”sounds simpleno evidence or ownershipverifiable acceptance criteria

The central idea

Agile means designing a step small enough to get evidence quickly. OOP means putting rules where they cannot be accidentally bypassed. Code First makes code the model's source while people remain responsible for database and production consequences. None removes the need for design or judgment.

A good model helps the team make the next change safely, beyond looking elegant on a diagram.

Take this into your project

Five questions before your next pull request

  1. Which business decision does this code protect?
  2. Does the model prevent invalid states?
  3. Does the database schema strengthen or weaken the rule?
  4. Does the migration tolerate old and new application versions?
  5. Which test or measurement proves the change works after deployment?