Skip to content

Getting Started: Add PATCH Support to a Mutation

This guide walks through adding HTTP PATCH support to an entity using Pragmatic.Patch and the Pragmatic source generator.

  • Pragmatic.Patch NuGet package referenced
  • Pragmatic.SourceGenerator configured as analyzer
  • An entity with settable properties

Start with an entity that has internal setters (the Pragmatic convention) or public setters:

public class Guest
{
public Guid Id { get; set; }
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string? Email { get; set; }
public string? Phone { get; set; }
public int LoyaltyPoints { get; set; }
// Pragmatic setter convention (generated by Persistence SG)
internal void SetFirstName(string value) => FirstName = value;
internal void SetLastName(string value) => LastName = value;
internal void SetEmail(string? value) => Email = value;
internal void SetPhone(string? value) => Phone = value;
internal void SetLoyaltyPoints(int value) => LoyaltyPoints = value;
}

Create a partial record annotated with [GeneratePatch<TEntity>]:

using Pragmatic.Patch.Attributes;
[GeneratePatch<Guest>]
public partial record UpdateGuestPatch;

The source generator will populate this with:

  • Optional<string> FirstName
  • Optional<string> LastName
  • Optional<string?> Email
  • Optional<string?> Phone
  • Optional<int> LoyaltyPoints
  • void ApplyTo(Guest entity)
  • IReadOnlySet<string> ModifiedProperties

Properties like Id are automatically excluded.

app.MapPatch("/api/guests/{id}", async (Guid id, UpdateGuestPatch patch, AppDbContext db) =>
{
var guest = await db.Guests.FindAsync(id);
if (guest is null)
return Results.NotFound();
patch.ApplyTo(guest);
await db.SaveChangesAsync();
return Results.Ok(guest);
});

If you use domain actions, inject the patch as an input:

public class UpdateGuest : DomainAction<GuestDto>
{
public required Guid Id { get; init; }
public required UpdateGuestPatch Patch { get; init; }
private IRepository<Guest, Guid> _repository = null!;
public override async Task<Result<GuestDto, IError>> Execute(CancellationToken ct = default)
{
var guest = await _repository.GetByIdAsync(Id, ct);
if (guest is null)
return new NotFoundError("Guest", Id);
Patch.ApplyTo(guest);
await _repository.SaveAsync(ct);
return guest.ToDto();
}
}

The generated JSON converter handles all three states automatically.

PATCH /api/guests/abc123
Content-Type: application/json
{
"email": "newemail@example.com"
}

Result: only Email is updated. FirstName, LastName, Phone, LoyaltyPoints are untouched.

PATCH /api/guests/abc123
Content-Type: application/json
{
"phone": null
}

Result: Phone is set to null. All other fields are untouched.

PATCH /api/guests/abc123
Content-Type: application/json
{
"firstName": "Alice",
"loyaltyPoints": 150
}

Result: FirstName and LoyaltyPoints are updated. Other fields untouched.

PATCH /api/guests/abc123
Content-Type: application/json
{}

Result: nothing changes. All properties remain Undefined.

You can check which fields were actually sent before applying:

var modified = patch.ModifiedProperties;
// e.g., {"email", "loyaltyPoints"}
if (modified.Contains("Email"))
{
// The client explicitly sent an email value (or null)
}

This is useful for audit logging or conditional business logic.

Step 6: Conditional Logic with Optional<T>

Section titled “Step 6: Conditional Logic with Optional<T>”

Use the Optional<T> API for fine-grained control:

// Execute only if the field was sent
patch.Email.IfPresent(email =>
{
if (email is null)
logger.LogInformation("Guest {Id} cleared their email", id);
else
logger.LogInformation("Guest {Id} changed email to {Email}", id, email);
});
// Transform the value
var upperName = patch.FirstName.Map(n => n?.ToUpperInvariant());
if (patch.Email.HasValue && patch.Email.Value is not null)
{
if (!IsValidEmail(patch.Email.Value))
return new ValidationError("Invalid email format");
}
patch.ApplyTo(guest);

If you need to apply only certain properties:

// Apply email only
if (patch.Email.HasValue)
guest.SetEmail(patch.Email.Value);
// Or check ModifiedProperties
if (patch.ModifiedProperties.Contains("LoyaltyPoints"))
guest.SetLoyaltyPoints(patch.LoyaltyPoints.Value);

Use the same entity for full replacement (PUT) and partial updates (PATCH):

// PUT: full replacement DTO
public record UpdateGuestRequest
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
public string? Email { get; init; }
public string? Phone { get; init; }
public int LoyaltyPoints { get; init; }
}
// PATCH: partial update
[GeneratePatch<Guest>]
public partial record UpdateGuestPatch;
// Wrong
[GeneratePatch<Guest>]
public record UpdateGuestPatch; // Missing 'partial'
// Correct
[GeneratePatch<Guest>]
public partial record UpdateGuestPatch;

The entity has no settable properties. Check that at least one property has either a public setter or a Set* method:

// No settable properties -- PRAG1902
public class Immutable
{
public Guid Id { get; }
public string Name { get; }
}
// Has settable properties -- works
public class Mutable
{
public Guid Id { get; }
public string Name { get; set; }
}

Properties not appearing in generated code

Section titled “Properties not appearing in generated code”

Check if the property falls into one of the excluded categories (identity, auditing, soft delete, navigation). See the README for the full list.