Files
budget/.plans/18-soft-delete-concurrency.md
Spencer Twaddle 087fbdd176 Split into Budget.Core / Budget.Infrastructure / Budget.Api projects
Budget.Core: entities, DTOs, enums, FrequencyCalculator (no EF/ASP.NET deps)
Budget.Infrastructure: AppDbContext, migrations, BudgetAuthorizationService
Budget.Api: controllers, middleware, Program.cs — references both projects

EF and Npgsql packages moved to Infrastructure; Api retains only JwtBearer,
HealthChecks, and EF.Design (needed for dotnet ef CLI). Dockerfile updated
to copy all three project directories before publishing. Migration namespaces
updated from Budget.Api.Data.* to Budget.Infrastructure.Data.* and model
type strings updated to Budget.Core.Models.* in the snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:30:31 -05:00

4.3 KiB

Plan: Soft Delete and Concurrency Tokens

Goal

Add soft delete (IsDeleted / DeletedAt + global EF query filter) and a xmin-based concurrency token (RowVersion) to the entities that benefit from them, matching the stwaddle stack convention.

Current state

  • Hard deletes on all entities — db.X.Remove(entity); await db.SaveChangesAsync().
  • No concurrency tokens — concurrent updates overwrite each other silently.
  • No IsDeleted / DeletedAt columns.

Scope decisions

Soft delete: Apply to Budget, Income, Outgo, and BudgetShare. KnownUser is a provisioning-only table; hard deletes are fine there.

Concurrency token: Apply to Budget only for now — it is the entity most likely to be edited by multiple users simultaneously (owner + shared editor). Income and Outgo updates are user-local; BudgetShare and KnownUser are low-contention. Extend later if needed.

Steps

Phase 1 — Update entity models

If plan #10 (project split) has been implemented, these files live in Budget.Core/Models/. Otherwise they are in Budget.Api/Models/.

  1. Add an ISoftDeletable interface (or base class) with bool IsDeleted, DateTimeOffset? DeletedAt.
  2. Implement on Budget, Income, Outgo, BudgetShare.
  3. Add byte[] RowVersion (mapped to xmin) to Budget only.

Phase 2 — Update AppDbContext / EF configuration

If plan #10 has been implemented, AppDbContext is in Budget.Infrastructure/Data/.

  1. In AppDbContext.OnModelCreating, for each soft-deletable entity add:
    builder.HasQueryFilter(x => !x.IsDeleted);
    
  2. For Budget, add the concurrency token config:
    builder.Property(e => e.RowVersion)
        .HasColumnName("xmin")
        .HasColumnType("xid")
        .ValueGeneratedOnAddOrUpdate()
        .IsConcurrencyToken();
    

Phase 3 — Update delete endpoints to soft delete

  1. In each controller, replace db.X.Remove(entity) with:
    entity.IsDeleted = true;
    entity.DeletedAt = DateTimeOffset.UtcNow;
    
    Affected actions: BudgetsController.Delete, IncomesController.Delete, OutgosController.Delete, SharesController.Revoke.

Phase 4 — Handle concurrency conflicts

  1. In BudgetsController.Update (and any other Budget write endpoint), wrap SaveChangesAsync in a try/catch for DbUpdateConcurrencyException and return Conflict(new { error = "The budget was modified by another user. Please refresh and retry." }).

Phase 5 — Migration

  1. dotnet ef migrations add AddSoftDeleteAndConcurrency --project Budget.Infrastructure --startup-project Budget.Api (adjust project flags if plan #10 has not been implemented yet).
  2. Review the generated migration — verify xmin column is not added as a normal column (it should appear only in modelSnapshot, not as an AddColumn since xmin is a Postgres system column).
  3. Apply: dotnet ef database update or let startup migrations handle it.

Phase 6 — Build and verify

  1. dotnet build — zero errors.
  2. Manually verify that a soft-deleted budget no longer appears in GET /api/budgets without any controller changes (query filter handles it).

Key decisions

  • No admin endpoint to view/restore deleted records is in scope here — add IgnoreQueryFilters() to a future admin endpoint if needed.
  • RowVersion is not exposed in DTOs or returned to the client in this plan. If optimistic concurrency is needed client-side (e.g., ETag), that is a separate concern.
  • Cascade behavior: when a Budget is soft-deleted its Incomes, Outgos, and BudgetShares are not automatically soft-deleted. They are filtered out indirectly because BudgetsController checks budget access before returning child resources. Explicit cascading can be added later.

Files affected

  • Budget.Core/Models/Budget.cs (or Budget.Api/Models/Budget.cs)
  • Budget.Core/Models/Income.cs
  • Budget.Core/Models/Outgo.cs
  • Budget.Core/Models/BudgetShare.cs
  • New: Budget.Core/Models/ISoftDeletable.cs (optional interface)
  • Budget.Infrastructure/Data/AppDbContext.cs (or Budget.Api/Data/AppDbContext.cs)
  • Budget.Api/Controllers/BudgetsController.cs
  • Budget.Api/Controllers/IncomesController.cs
  • Budget.Api/Controllers/OutgosController.cs
  • Budget.Api/Controllers/SharesController.cs
  • New migration file