A JSON Utility Approach for Handling HTTP PATCH in Apex

When working with RESTful APIs, HTTP PATCH is the method used to perform partial updates on a resource. Unlike PUT, which requires sending and overwriting the entire resource, PATCH allows clients to send only the fields they want to update.

This makes PATCH more efficient—and often safer—when only small changes are needed.

Background

In integration scenarios that use Apex REST APIs, data is typically exchanged through well-defined models. These models are implemented as Apex classes that structure the request and response payloads for consistent communication between Salesforce and external applications.

Each model mirrors selected fields from Salesforce SObjects and acts as our contract for data exchange.

The Challenge: PATCH + JSON.deserialize()

When the frontend sends a PATCH request, it often includes only one updated field. For example, updating just the Title field on a Contact.

But here’s the problem:

When we deserialize the request body using JSON.deserialize(), Apex creates a full instance of the model class and sets any missing fields to null.

Why is this a problem?

Because we can no longer distinguish between:

  • A field the frontend intentionally set to null, vs.
  • A field that was simply not included in the PATCH request.

If we blindly save those nulls, we’d accidentally wipe out data that was never meant to be changed.

The Solution

Instead of deserializing the JSON directly into a model class, we first:

  1. Read the raw JSON,
  2. Check which fields are actually present, and
  3. Update only those fields on the SObject.

This ensures that only the intended fields are updated—true to how PATCH is meant to work.

By inspecting the JSON itself, we avoid unintended overwrites and can confidently apply partial updates.


Example of using the JSON Utility

public static void updateObject(String objectJSON, String objectId) {
    JSONUtil.setJson(objectJSON);
    JSONUtil.setIdField('Id', objectId);
    JSONUtil.setFieldMapping(getFieldMapping(), SomeObject__c.getSObjectType());
    SomeObject__c toUpdate = (SomeObject__c) JSONUtil.getPopulatedSObject();
    populateUnmappedFields(toUpdate);
    //Some logic...
}

public static Map<String, String> getFieldMapping() {
    return new Map<String, String>{
        'title' => 'Title',
        'xtraInfo' => 'Info__c'
    };
}

public static void populateUnmappedFields(SomeObject__c objectFromJSON) {
    if(JSONUtil.containsProperty('installationId')) {
        String installationId = (String) JSONUtil.getPropertyValue('installationId');
        //Some logic...
  • Record Id can be the Standard SF Id or an External Id field. Usually the Id is provided separately in the Request URI (PATCH …/someobject/123456), hence it is defined and stored separately to the object.
  • Provided field mapping from “Model” to SObject is used for fields that CAN be directly mapped to the SObject.
  • If a JSON property is not part of the SObject, or requires special processing, it has to be done separately. Unmapped or special fields, or objects within the JSON, can be accessed separately.
    • Use “containsProperty” method to check if a property was defined within the JSON
    • Use “getPropertyValue” method to get the property value
  • There is NO limitation that this should be only used for HTTP PATCH

JSON Utility

Note: still a bit work in progress.

public class JSONUtil {
    private static String recordId;
    private static String recordIdField;
    private static Map<String, Object> jsonFields;
    private static Map<String, String> fieldMapping;
    private static SObjectType populatedSObjectType;

    public class JSONUtilException extends Exception {}

    /************************************************************************************************************
	* @description Provide a JSON for processing
	* @param jsonModel Mapping from JSON fields to SObject fields
	*/
    public static void setJson(String jsonModel) {
        Map<String, Object> jsonFieldsForMapping = (Map<String, Object>) JSON.deserializeUntyped(jsonModel);
        jsonFields = new Map<String, Object>();
        for(String jsonFieldName : jsonFieldsForMapping.keySet()) {
            jsonFields.put(jsonFieldName.toLowerCase(), jsonFieldsForMapping.get(jsonFieldName));
        }
    }

    /************************************************************************************************************
	* @description Set fields and SObjectType in order to populate an SObject
	* @param modelToSObjectFieldMap Mapping from JSON fields to SObject fields
	* @param sObjectType SObjectType is used when instantiating a new SObject with populated fields
	*/
    public static void setFieldMapping(Map<String, String> modelToSObjectFieldMap, SObjectType sObjectType) {
        populatedSObjectType = sObjectType;
        fieldMapping = new Map<String, String>();
        for(String modelFieldName : modelToSObjectFieldMap.keySet()) {
            fieldMapping.put(modelFieldName.toLowerCase(), modelToSObjectFieldMap.get(modelFieldName));
        }
    }

    /************************************************************************************************************
	* @description Provide an Id for the SObject if defined separately e.g. in requestUri
	* @param sObjectIdField Field API Name e.g. standard Id or a custom field
	* @param idValue Id value for the SObject
	*/
    public static void setIdField(String sObjectIdField, String idValue) {
        recordIdField = sObjectIdField;
        recordId = idValue;
    }

    /************************************************************************************************************
	* @description Populate an SObject using the available JSON properties and fieldMapping
	* @return SObject
	*/
    public static SObject getPopulatedSObject() {
        SObject so = populatedSObjectType.newSObject();
        setIdFieldToSObject(so);

        //Object values do not sit well with Date & Datetime field assignments
        Map<String, SObjectField> sObjectFields = populatedSObjectType.getDescribe().fields.getMap();
        DisplayType typeDate = DisplayType.DATE;
        DisplayType typeDatetime = DisplayType.DATETIME;

        for(String jsonFieldName : jsonFields.keySet()) {
            if(fieldMapping.containsKey(jsonFieldName)) {
                String sObjectFieldApiName = fieldMapping.get(jsonFieldName);
                DisplayType fieldDisplayType;
                try {
                    fieldDisplayType = sObjectFields.get(sObjectFieldApiName).getDescribe().getType();
                } catch (Exception e) {
                    throw new JSONUtilException('Invalid SObject field "' + sObjectFieldApiName + '"');
                }
                Object fieldValue = jsonFields.get(jsonFieldName);
                if(fieldDisplayType == typeDate && fieldValue != null) {
                    so.put(sObjectFieldApiName, JSON.deserialize('"' + fieldValue + '"', Date.class));
                } else if(fieldDisplayType == typeDatetime && fieldValue != null) {
                    so.put(sObjectFieldApiName, JSON.deserialize('"' + fieldValue + '"', Datetime.class));
                } else {
                    so.put(sObjectFieldApiName, fieldValue);
                }
            }
        }
        return so;
    }

    /************************************************************************************************************
	* @description Check if the JSON contains a property
	* @param propertyName Property name to check against the JSON
	* @return True if JSON contains the property
	*/
    public static Boolean containsProperty(String propertyName) {
        return jsonFields.containsKey(propertyName.toLowerCase());
    }

    /************************************************************************************************************
	* @description Get the value of a JSON property
	* @param propertyName Property name to check against the JSON
	* @return Untyped object representation of the value
	*/
    public static Object getPropertyValue(String propertyName) {
        return jsonFields.get(propertyName.toLowerCase());
    }

    /************************************************************************************************************
	* @description Clear all stored variables
	*/
    public static void reset() {
        recordId = null;
        recordIdField = null;
        jsonFields = null;
        fieldMapping = null;
        populatedSObjectType = null;
    }

    private static void setIdFieldToSObject(SObject so) {
        if(String.isNotBlank(recordId) && String.isNotBlank(recordIdField)) {
            so.put(recordIdField, recordId);
        }
    }
}

Unit Tests

Note: if used, change the failing references (Last_login__c)

@IsTest
private class JSONUtilTest {

    @IsTest
    static void testInitializeSObjectFromJSON() {
        SObjectType sObjectType = Contact.getSObjectType();
        Map<String, String> fieldMapping = getFieldMapping();

        String jsonWithNonNulls = '{' +
            '"stringValue": "Testing",' +
            '"doubleValue": 2.0,' +
            '"booleanValue": true,' +
            '"dateValue": "2025-04-01",' +
            '"datetimeValue": "2025-04-01T16:01:36.758Z",' +
            //Unmapped Fields
            '"irrelevantField": "NotMapped",' +
            '"childRecords": [{},{}]' +
            '}';

        String jsonWithNulls = '{' +
            '"stringValue": null,' +
            '"doubleValue": null,' +
            '"booleanValue": null,' +
            '"dateValue": null,' +
            '"datetimeValue": null' +
            '}';

        //Initialize the class with a JSON & Check that all properties are correctly identified as having values
        JSONUtil.setJson(jsonWithNonNulls);

        //Add a field that should be skipped on SObject population (missing from JSON but correct field mapping)
        fieldMapping.put('MissingFromJSON', 'LastName');
        JSONUtil.setFieldMapping(fieldMapping, sObjectType);

        SObject populatedSObject = JSONUtil.getPopulatedSObject();

        Assert.areEqual(sObjectType, populatedSObject.getSObjectType(), 'SObjectType should match');
        Assert.areEqual(5, populatedSObject.getPopulatedFieldsAsMap().size(),
            'SObject should contain 5 populated fields');

        //Verify JSON property values and populated SObject field values
        for(String jsonPropertyName : fieldMapping.keySet()) {
            if(jsonPropertyName == 'MissingFromJSON') {
                Assert.isFalse(JSONUtil.containsProperty(jsonPropertyName), 'JSON property should not be set');
                Assert.isFalse(populatedSObject.isSet(fieldMapping.get(jsonPropertyName)),
                    'SObject field should not be set');
            } else {
                Assert.isTrue(JSONUtil.containsProperty(jsonPropertyName), 'JSON property should be set');
                Assert.isNotNull(populatedSObject.get(fieldMapping.get(jsonPropertyName)),
                    'Populated SObject field value should not be null');
            }
        }

        //Check unmapped fields
        Assert.isTrue(JSONUtil.containsProperty('IrrelevantField'), 'JSON property should be set');
        Assert.isNotNull(JSONUtil.getPropertyValue('irrelevantField'), 'JSON property value should not be null');
        Assert.isTrue(JSONUtil.containsProperty('childRecords'), 'JSON property should be set');
        Assert.isNotNull(JSONUtil.getPropertyValue('childRecords'), 'JSON property value should not be null');

        //Add Id field definition
        JSONUtil.setIdField('Id', sObjectType.getDescribe().getKeyPrefix() + ('0'.repeat(15)));
        populatedSObject = JSONUtil.getPopulatedSObject();
        Assert.areEqual(6, populatedSObject.getPopulatedFieldsAsMap().size(),
            'SObject should contain 6 populated fields');

        //Add an invalid SObject Field to Mapping - this should cause an exception
        fieldMapping.put('irrelevantField', 'Invalid_SObject_Field__c');
        JSONUtil.setFieldMapping(fieldMapping, sObjectType);
        try {
            JSONUtil.getPopulatedSObject();
            Assert.fail('Logic should not work without throwing an exception');
        } catch(Exception e) {
            Assert.areEqual(e.getTypeName(), JSONUtil.JSONUtilException.class.getName(),
                'Should throw a correct Exception type');
        }

        //Test with NULL values
        JSONUtil.reset();
        JSONUtil.setJson(jsonWithNulls);
        JSONUtil.setFieldMapping(getFieldMapping(), sObjectType);
        populatedSObject = JSONUtil.getPopulatedSObject();

        for(String jsonPropertyName : getFieldMapping().keySet()) {
            Assert.isTrue(JSONUtil.containsProperty(jsonPropertyName), 'JSON property should be set');
            Assert.isNull(populatedSObject.get(fieldMapping.get(jsonPropertyName)),
                'Populated SObject field value should be null');
        }
    }

    private static Map<String, String> getFieldMapping() {
        return new Map<String, String>{
            'stringValue' => 'FirStnaMe', //Incorrect Lower/Uppercase must not affect the logic
            'doubleValue' => 'MailingLatitude',
            'booleanValue' => 'HasOptedOutOfEmail',
            'dateValue' => 'Birthdate',
            'datetimeValue' => 'Last_login__c'
        };
    }
}

Article written originally by Aleksi Rajakoski

Leave a comment