Common Mistakes
These are the most common issues developers encounter when using Pragmatic.Patch. Each section shows the wrong approach, the correct approach, and explains why.
1. Forgetting partial on the Patch Record
Section titled “1. Forgetting partial on the Patch Record”Wrong:
[GeneratePatch<Guest>]public record UpdateGuestPatch;Compile result: PRAG1900 error — “[GeneratePatch] must be applied to a partial class or record”.
Right:
[GeneratePatch<Guest>]public partial record UpdateGuestPatch;Why: The source generator emits Optional<T> properties, ApplyTo(), ModifiedProperties, and a JSON converter into a partial class. Without partial, the compiler cannot merge the generated code with your declaration.
2. Confusing Null and Undefined
Section titled “2. Confusing Null and Undefined”Wrong:
// Treating null the same as "not sent"if (patch.Email.Value is null){ // Skip -- assume the client didn't send it}Right:
// Check IsUndefined for "not sent"if (patch.Email.IsUndefined){ // Client did not include "email" in the JSON body -- skip}
// Check HasValue + null for "clear the value"if (patch.Email.HasValue && patch.Email.Value is null){ // Client explicitly sent { "email": null } -- clear the value}Why: Optional<T> distinguishes three states. null means the client explicitly sent null (meaning “clear this field”). IsUndefined means the field was absent from the JSON body (meaning “don’t touch this field”). Treating them the same defeats the purpose of tri-state semantics.
3. Accessing Value Without Checking HasValue
Section titled “3. Accessing Value Without Checking HasValue”Wrong:
var email = patch.Email.Value; // Throws if undefined!logger.LogInformation("New email: {Email}", email);Right:
if (patch.Email.HasValue){ logger.LogInformation("New email: {Email}", patch.Email.Value);}
// Or use GetValueOrDefaultvar email = patch.Email.GetValueOrDefault("unknown");
// Or use IfPresentpatch.Email.IfPresent(email => logger.LogInformation("New email: {Email}", email));Why: Accessing .Value on an undefined Optional<T> throws InvalidOperationException. Always check HasValue first, or use GetValueOrDefault() or IfPresent() for safe access.
4. Using Optional<T> Without Registering the JSON Converter
Section titled “4. Using Optional<T> Without Registering the JSON Converter”Wrong:
// Manual DTO with Optional<T> -- no JSON converter registeredpublic class ManualPatchDto{ public Optional<string> Name { get; init; } // Always Undefined after deserialization! public Optional<decimal> Price { get; init; } // Always Undefined!}Right (Option A): Use the source generator:
[GeneratePatch<Product>]public partial record PatchProduct; // SG generates the converter automaticallyRight (Option B): Register the fallback converter factory:
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new OptionalConverterFactory()));Why: Without a JSON converter, System.Text.Json does not know how to deserialize Optional<T>. The struct has no public constructor that matches the JSON shape, so all properties remain default (Undefined). The SG-generated patch types include a [JsonConverter] attribute automatically. If you use Optional<T> in a manually-written DTO, you must register the OptionalConverterFactory globally.
5. Expecting Null to Work for Non-Nullable Value Types
Section titled “5. Expecting Null to Work for Non-Nullable Value Types”Wrong:
// Expecting this to set Price to Optional.Null// JSON: { "price": null }if (patch.Price.HasValue && patch.Price.Value is null){ // This branch never executes -- Price is Optional<decimal>, not Optional<decimal?>}Right:
// For non-nullable value types, null in JSON means Undefined (not sent)// JSON: { "price": null } => Price = Optional<decimal>.Undefined// JSON: { "price": 0 } => Price = Optional.Of(0m) (explicit zero)
// If you need nullable semantics, use a nullable entity property:public class Product{ public decimal? DiscountRate { get; set; } // Nullable -- Optional<decimal?> supports null}Why: decimal cannot be null. When the JSON contains "price": null, the converter cannot create an Optional<decimal>.Null because decimal has no null representation. Instead, it treats this as Undefined. If you need to allow null values, make the entity property nullable (decimal?), which generates Optional<decimal?> in the patch DTO.
6. Manually Applying Properties Instead of Using ApplyTo
Section titled “6. Manually Applying Properties Instead of Using ApplyTo”Wrong:
// Manually checking and applying every fieldif (patch.FirstName.HasValue) guest.SetFirstName(patch.FirstName.Value!);if (patch.LastName.HasValue) guest.SetLastName(patch.LastName.Value!);if (patch.Email.HasValue) guest.SetEmail(patch.Email.Value);if (patch.Phone.HasValue) guest.SetPhone(patch.Phone.Value);if (patch.LoyaltyPoints.HasValue) guest.SetLoyaltyPoints(patch.LoyaltyPoints.Value);Right:
patch.ApplyTo(guest);Why: The generated ApplyTo() method does exactly the same thing, but it stays in sync with the entity automatically. When you add a new property to the entity, the SG regenerates ApplyTo() to include it. Manual application requires updating the code every time the entity changes — and forgetting to add a new property is a silent bug.
Use manual application only when you need conditional logic per field (e.g., validation before apply):
if (patch.Email.HasValue && patch.Email.Value is not null){ if (!IsValidEmail(patch.Email.Value)) return new ValidationError("Invalid email format");}patch.ApplyTo(guest);7. Applying a Patch to the Wrong Entity Type
Section titled “7. Applying a Patch to the Wrong Entity Type”Wrong:
[GeneratePatch<Guest>]public partial record UpdateGuestPatch;
// Trying to apply to a different entityvar product = await db.Products.FindAsync(id);patch.ApplyTo(product); // Compile error -- wrong typeRight:
var guest = await db.Guests.FindAsync(id);patch.ApplyTo(guest); // Correct -- Guest matches [GeneratePatch<Guest>]Why: The generated ApplyTo() method is strongly typed to the entity specified in [GeneratePatch<TEntity>]. This is by design — it prevents applying a guest patch to a product. If you need to patch multiple entity types, create separate patch DTOs for each.
8. Expecting Excluded Properties in the Patch
Section titled “8. Expecting Excluded Properties in the Patch”Wrong:
public class Guest{ public Guid Id { get; set; } public string Name { get; set; } = ""; public DateTime CreatedAt { get; set; } // Auditing property public bool IsDeleted { get; set; } // Soft delete property}
[GeneratePatch<Guest>]public partial record UpdateGuestPatch;
// Expecting these to exist -- they don'tpatch.Id // Compile error -- Id is excludedpatch.CreatedAt // Compile error -- auditing properties are excludedpatch.IsDeleted // Compile error -- soft delete properties are excludedRight:
// Only Name is generated (the only patchable property)if (patch.Name.HasValue) logger.LogInformation("Name changed to {Name}", patch.Name.Value);Why: The generator automatically excludes properties that should never be modified through a PATCH request: identity (Id, PersistenceId), auditing (CreatedAt, CreatedBy, UpdatedAt, UpdatedBy), soft delete (IsDeleted, DeletedAt, DeletedBy), concurrency (RowVersion), collections, and entity references. These are managed by the framework, not by API consumers.
9. Not Checking ModifiedProperties for Audit or Conditional Logic
Section titled “9. Not Checking ModifiedProperties for Audit or Conditional Logic”Wrong:
// Logging all fields regardless of whether they changedpatch.ApplyTo(guest);logger.LogInformation("Guest updated: Name={Name}, Email={Email}", guest.FirstName, guest.Email);Right:
// Log only what actually changedvar modified = patch.ModifiedProperties;patch.ApplyTo(guest);
foreach (var prop in modified){ logger.LogInformation("Guest {Id}: {Property} was modified", guest.Id, prop);}Why: ModifiedProperties returns an IReadOnlySet<string> of property names that were present in the JSON input. This is valuable for audit logging, conditional business logic (e.g., “send a notification only if the email changed”), and optimized persistence (only update changed columns).
10. Using JsonPatchDocument Instead of GeneratePatch
Section titled “10. Using JsonPatchDocument Instead of GeneratePatch”Wrong:
// Loses type safety, requires the client to know JSON Patch operationsapp.MapPatch("/products/{id}", (Guid id, JsonPatchDocument<Product> patch, AppDbContext db) =>{ var product = db.Products.Find(id); patch.ApplyTo(product); // No compile-time validation of operation paths db.SaveChanges(); return Results.Ok(product);});Right:
[GeneratePatch<Product>]public partial record PatchProduct;
app.MapPatch("/products/{id}", async (Guid id, PatchProduct patch, AppDbContext db) =>{ var product = await db.Products.FindAsync(id); if (product is null) return Results.NotFound();
patch.ApplyTo(product); await db.SaveChangesAsync(); return Results.Ok(product);});Why: JsonPatchDocument<T> requires the client to send JSON Patch operations (op, path, value), which is more complex for API consumers. It also bypasses entity encapsulation — operations can target any property path, including those that should not be modified. [GeneratePatch<T>] generates a type-safe DTO with excluded properties, encapsulated setters, and a simple JSON body that any client can produce.