Skip to content

Typed patch updates from partial entities on Mongo repositories #5229

Description

@samuelfabel

Summary

Add a first-class, explicitly named patch API on Mongo repositories / MongoOperations that applies a typed partial entity as a $set-only update against the repository’s aggregate type T, without loading the full document and without replacing unmapped fields.

Naming parallel with the existing API:

  • save(entity) — persist / replace the aggregate
  • patch(partialEntity) — write only the fields mapped by the partial type

This is not a request to change save() semantics and not a request to treat a half-populated instance of T as an update stamp. A partial entity is a dedicated type whose mapped properties are a subset of T.

Motivation

In systems where multiple services share a MongoDB collection (or where a service only owns a subset of fields), the current options are awkward:

  1. CrudRepository.save(entity) replaces the whole document. When the Java type maps only a subset of fields, properties that exist in MongoDB but are absent from that type are not preserved across the write.
  2. MongoTemplate + Update works, but pushes teams toward untyped field paths / JSON update strings.
  3. Repository @Update is useful for fixed updates, but does not scale well for “apply this typed slice of fields” use cases.

Teams often invent local helpers (converter.write$set). A supported patch would make the intent explicit and keep that pattern consistent.

Concrete scenario

  • Collection customers
  • Billing service maps { id, billing.plan, billing.status }
  • Shipping service maps { id, shipping.address }
  • Billing needs to update its fields while leaving shipping.* unchanged

With the current API, persisting a billing-only mapped type through save(...) performs a full document replace, so fields outside that type are not retained. A dedicated patch(billingPartial) would express “write only these mapped fields” without requiring a full-document read/merge.

Why this is different from #2642

#2642 asked for partial updates from a partially filled domain POJO, and was declined mainly because:

  1. A half-filled aggregate as an update stamp conflicts with Spring Data’s DDD-oriented model.
  2. “Non-null fields are written” cannot express “set this field to null”.

This proposal avoids both issues:

Concern in #2642 This proposal
Half-filled aggregate root (T with some nulls) Separate partial entity type, validated against T
Overloaded / implicit save Explicit patch API; save unchanged
Null ambiguity Explicit null policy (see below)

Proposed API

The core idea is an explicit patch operation. Where it lives can be decided by the team; a few options:

Option A — extend the existing repository type

Expose patch on MongoRepository (or a shared Spring Data abstraction, if that fits better):

<P> long patch(P partialEntity);
<P> long patch(ID id, P partialEntity);

Existing repositories would gain the method without introducing a new interface.

Option B — dedicated repository type / fragment

Keep MongoRepository unchanged and offer an opt-in type for apps that need patching:

@NoRepositoryBean
public interface PatchMongoRepository<T, ID> extends MongoRepository<T, ID> {

  <P> long patch(P partialEntity);
  <P> long patch(ID id, P partialEntity);
}

Or the same methods via a repository fragment, so only selected repositories opt in.

Option C — MongoOperations first

Start at the template level, then decide whether/how to surface it on repositories:

<T, P> UpdateResult patch(P partialEntity, Class<T> entityClass);
<T, P> UpdateResult patch(Object id, P partialEntity, Class<T> entityClass);
<T, P> UpdateResult patch(Query query, P partialEntity, Class<T> entityClass);

Example (shape of the call)

@Document("customers")
class Customer {
  @Id String id;
  Billing billing;
  Shipping shipping;
}

record BillingPlanPartial(
    @Id String id,
    @Field("billing.plan") String plan,
    @Field("billing.status") String status
) {}

// Depending on the chosen option:
// - CustomerRepository extends MongoRepository<Customer, String>
// - or CustomerRepository extends PatchMongoRepository<Customer, String>

customerRepository.patch(new BillingPlanPartial(id, "pro", "ACTIVE"));
// => db.customers.updateOne({_id: id}, {$set: {"billing.plan": "pro", "billing.status": "ACTIVE"}})

Unmapped fields such as shipping remain untouched. No full-document read is required.

Mapping rules (partial entity → $set)

  1. Repository / operation remains typed to aggregate T (collection + mapping metadata come from T).
  2. P is a partial entity type, not necessarily a subtype of T.
  3. Each writable property in P maps to a Mongo field path via normal Spring Data mapping (@Field, naming strategy, etc.).
  4. Suggested compatibility rule: property names/types in P must be assignable to a property path on T (runtime validation using persistent entity metadata; optional compile-time checks can live outside core).
  5. Nested documents should prefer dotted paths (billing.plan) so patching does not replace an entire subdocument unless the partial entity intentionally maps the whole subdocument.
  6. Metadata fields such as _id / _class are not $set unless explicitly designed that way (_id identifies the document).

Null / presence semantics

Do not use “null means skip” as the only model.

Proposed options (API detail open for discussion):

  1. Default for plain nullable fields: include $set only for properties considered “present”.
  2. Explicit absent vs null: support wrapper types already common in the ecosystem, e.g.:
    • Optional<T> / dedicated patch wrapper
    • or an annotation such as @PatchNull / unset marker
  3. Explicit unset: allow mapping a present sentinel / annotation to $unset.

The important part: null handling must be documented and opt-in/clear, not inferred only from Java null on a partially constructed aggregate.

Non-goals

  • Changing CrudRepository.save / replace semantics
  • Treating a partially populated instance of T as a patch stamp
  • Replacing @Update string/pipeline updates for fixed derived queries
  • Requiring a Lombok-like annotation processor in spring-data-mongodb core
    (compile-time partial-entity validation can be a separate community module later)

Benefits

  • Strongly typed patches without untyped Update path strings everywhere
  • Fits shared collections / field ownership across services
  • Clear write-side counterpart to read projections: projections for read, partial entities for patch
  • Preserves DDD purity of save(entity) while offering an explicit escape hatch for PATCH-like persistence

Suggested acceptance criteria

  • patch(partialEntity) / patch(id, partialEntity) emit $set only for mapped partial-entity properties
  • Fields present in the document but absent from the partial entity are preserved
  • Mapping honors @Field and persistent property metadata of T
  • Null/presence behavior is explicit and tested
  • save behavior remains unchanged
  • Imperative and reactive variants (MongoOperations / ReactiveMongoOperations, repository support as chosen)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions