Skip to content

Latest commit

 

History

History
249 lines (191 loc) · 12.3 KB

File metadata and controls

249 lines (191 loc) · 12.3 KB

Save Operation

Save is the persistence routing heart of RemoteFactory. Instead of the caller deciding whether to insert, update, or delete, the entity tracks its own lifecycle state and Save routes to the right operation. This matters most with object graphs — a parent aggregate calls Save once, and each entity in the graph handles its own persistence based on its own state, cascading the save to its children. One call at the root handles inserts, updates, and deletes across the entire graph, regardless of what the user did in the UI.

This pattern comes from CSLA's DataPortal — the data mapper principle that each entity knows how to save itself.

IFactorySaveMeta

To opt into Save routing, implement IFactorySaveMeta. It requires two properties:

// IFactorySaveMeta requires: IsNew (true for new entities) and IsDeleted (true for deletion)
public bool IsNew { get; private set; } = true;
public bool IsDeleted { get; set; }

snippet source | anchor

Why two booleans instead of an enum? These are regular properties that participate in data binding. IsDeleted is user-settable — the UI can bind a delete button to it. IsNew is set by your Create and Fetch methods. Both properties naturally flow from UI state into persistence routing without any framework-managed state machine.

When a class implements IFactorySaveMeta, RemoteFactory generates an IFactorySave<T> interface on its factory with a Save() method.

Serializing IsNew and IsDeleted Across the Remote Boundary

Save routing happens server-side — the generated LocalSave reads target.IsNew and target.IsDeleted on the server-side instance. For that to work in NeatooFactory.Remote mode, both properties must round-trip through JSON when the client sends the entity to the server.

System.Text.Json serializes public getters by default, so public bool IsNew { get; set; } works out of the box. But many domain designs want to prevent external callers from flipping lifecycle state ad-hoc (entity.IsNew = false is nonsense from a UI) and use private setters:

public bool IsNew { get; private set; } = true;
public bool IsDeleted { get; private set; }

A private setter is invisible to STJ's default reflection-based resolver on the deserializing side — the server will construct a fresh IsNew = true default and every Save will route to Insert. Add [JsonInclude] to force STJ to use the non-public setter on both ends:

[JsonInclude]
public bool IsNew { get; private set; } = true;

[JsonInclude]
public bool IsDeleted { get; private set; }

Provide a domain-flavored mutation path for IsDeleted so callers don't need the setter:

public void MarkDeleted() => this.IsDeleted = true;

IsNew is typically flipped by your own [Fetch] / [Insert] methods (this.IsNew = false; works from inside the same class regardless of setter visibility).

IL trimming: private setters interact badly with the trimmer's visibility analysis — see Trimming: IFactorySaveMeta Preservation. If you run PublishTrimmed=true, you need an additional [DynamicDependency] annotation on the constructor.

Routing Logic

Save inspects IsNew and IsDeleted to decide which operation to call:

IsNew IsDeleted Operation Why
true false Insert New entity, not yet persisted
false false Update Existing entity with changes
false true Delete Existing entity marked for removal
true true No-op Created and deleted before saving — nothing to persist

// Save routing: IsNew=true -> Insert, IsNew=false -> Update, IsDeleted=true -> Delete
// | IsNew | IsDeleted | Operation |
// |-------|-----------|-----------|
// | true  | false     | Insert    |
// | false | false     | Update    |
// | false | true      | Delete    |
// | true  | true      | no-op     |

snippet source | anchor

Write Operations

Each write operation is a method on your domain class. You write the persistence logic; Save routes to the right one:

// Save routes to Insert/Update/Delete based on IsNew and IsDeleted flags
[Remote, Insert]
internal async Task Insert([Service] IEmployeeRepository repo, CancellationToken ct)
{
    await repo.AddAsync(new EmployeeEntity { Id = Id, FirstName = FirstName }, ct);
    IsNew = false;
}

[Remote, Update]
internal async Task Update([Service] IEmployeeRepository repo, CancellationToken ct)
{
    var e = await repo.GetByIdAsync(Id, ct);
    if (e != null) { e.FirstName = FirstName; await repo.UpdateAsync(e, ct); }
}

[Remote, Delete]
internal async Task Delete([Service] IEmployeeRepository repo, CancellationToken ct)
{
    await repo.DeleteAsync(Id, ct);
}

snippet source | anchor

The caller just calls Save — the routing is invisible to them:

// Save routes based on state: Insert (IsNew=true), Update (IsNew=false), Delete (IsDeleted=true)
var saved = await _factory.Save(employee);       // Insert
saved = await _factory.Save((EmployeeCrud)saved!); // Update
((EmployeeCrud)saved!).IsDeleted = true;
await _factory.Save((EmployeeCrud)saved);        // Delete

snippet source | anchor

Cascading Saves

Save's real power shows in object graphs. When a parent aggregate saves, it can cascade the save to its children. Each child entity knows its own state (IsNew, IsDeleted) and handles its own persistence, then continues the cascade to its children. The result: one Save() call at the root persists the entire graph — new children are inserted, modified children are updated, removed children are deleted — all in one operation.

You implement the cascade in your Insert/Update methods by calling Save on child collections. Each child entity's IsNew/IsDeleted state drives its own routing independently.

Partial Operations

You don't need all three write operations. Save routes based on what you've defined:

// Insert-only entity: no Update/Delete means records are immutable after creation
[Factory]
public partial class AuditLog : IFactorySaveMeta
{
    public Guid Id { get; private set; }
    public string Action { get; set; } = "";
    public bool IsNew { get; private set; } = true;
    public bool IsDeleted { get; set; }

    [Create] public AuditLog() { Id = Guid.NewGuid(); }
    [Remote, Insert] internal Task Insert(CancellationToken ct) { IsNew = false; return Task.CompletedTask; }
    // No [Update] or [Delete] = immutable after insert
}

snippet source | anchor

Common patterns:

Operations Defined Pattern Example
Insert only Immutable records Audit logs, event sourcing entries
Insert + Update Full write, no delete Soft-delete systems
Insert + Update + Delete Full CRUD Standard entities

For upsert (same method for Insert and Update), mark a single method with both [Insert, Update]. See Factory Operations.

Return Values

Task<IFactorySaveMeta?> Save(T entity, CancellationToken cancellationToken = default);

Returns the entity on success. Returns null when:

  • The write operation returns false (not authorized or not found)
  • IsNew = true and IsDeleted = true (no-op)

Save vs Explicit Methods

Save is optional. You can always call Insert, Update, or Delete directly through the factory.

// Save: state-based routing (single "Save" button in UI)
await _factory.Save(employee);           // Routes to Insert (IsNew=true)
employee.FirstName = "Jane";
await _factory.Save(employee);           // Routes to Update (IsNew=false)
employee.IsDeleted = true;
await _factory.Save(employee);           // Routes to Delete (IsDeleted=true)

snippet source | anchor

Use Save when the UI has a single save button and the entity tracks its own state. Use explicit methods when the client knows the exact operation (e.g., separate "Create" and "Edit" pages).

Optimistic Concurrency

Use version tokens or timestamps for conflict detection:

// Add RowVersion property; EF Core throws DbUpdateConcurrencyException on conflict -> 409 response
[Factory]
public partial class ConcurrentEmployee : IFactorySaveMeta
{
    public byte[]? RowVersion { get; private set; }  // Concurrency token, auto-updated by database
    public bool IsNew { get; private set; } = true;
    public bool IsDeleted { get; set; }
    /* Fetch loads RowVersion, Update includes it for conflict detection */
}

snippet source | anchor

EF Core's DbUpdateConcurrencyException automatically becomes a 409 response when called remotely.

Authorization and Validation

Save respects authorization rules — you can authorize at the operation level (separate permissions for Insert vs Update vs Delete) or at the Write level (single permission for all writes). See Authorization for details.

Validate before Save however you prefer — DataAnnotations, FluentValidation, or manual checks in your write methods. RemoteFactory doesn't prescribe a validation approach.

Testing Save Routing

Verify that Save routes to the correct operation based on entity state:

// Test Save routing: IsNew=true -> Insert, IsNew=false -> Update, IsDeleted=true -> Delete
[Fact]
public async Task Save_WhenIsNewTrue_RoutesToInsert()
{
    var scopes = TestClientServerContainers.CreateScopes();
    var factory = scopes.local.ServiceProvider.GetRequiredService<IEmployeeCrudFactory>();
    var employee = factory.Create();
    Assert.True(employee.IsNew);
    var saved = (EmployeeCrud)(await factory.Save(employee))!;
    Assert.False(saved.IsNew);  // Insert sets IsNew = false
}

snippet source | anchor

Next Steps