diff --git a/pom.xml b/pom.xml
index a8d772b79198ebb194fea01de82766addcfb390b..4953adf256ccf345c4b9d874566b0f7c46efc584 100644
--- a/pom.xml
+++ b/pom.xml
@@ -149,7 +149,7 @@
 		<dependency>
 			<groupId>com.floragunn</groupId>
 			<artifactId>fluent-collections</artifactId>
-			<version>0.2.1</version>
+			<version>0.2.2</version>
 		</dependency>
 
 		<dependency>
diff --git a/src/main/java/com/floragunn/codova/documents/DocNode.java b/src/main/java/com/floragunn/codova/documents/DocNode.java
index 2d34d146190c9d7e93e140b70bea2d7d23e38e53..21df8c228c771256093d74af7b744158d7a2aaa5 100644
--- a/src/main/java/com/floragunn/codova/documents/DocNode.java
+++ b/src/main/java/com/floragunn/codova/documents/DocNode.java
@@ -670,6 +670,10 @@ public abstract class DocNode implements Map<String, Object>, Document<Object> {
         }
     }
 
+    public static DocNode array(Object... array) {
+        return new PlainJavaObjectAdapter(ImmutableList.ofArray(array));
+    }
+
     private static void add(OrderedImmutableMap.Builder<String, Object> builder, String key, Object value) {
         int s = key.indexOf('.');
 
@@ -1075,6 +1079,7 @@ public abstract class DocNode implements Map<String, Object>, Document<Object> {
 
             return mapBuilder;
         }
+
     }
 
     protected String key;
@@ -1093,6 +1098,10 @@ public abstract class DocNode implements Map<String, Object>, Document<Object> {
 
     public abstract boolean isList(String attribute);
 
+    public boolean isScalar() {
+        return !isMap() && !isList() && !isNull();
+    }
+
     public abstract ImmutableList<Object> toList();
 
     public abstract DocNode splitDottedAttributeNamesToTree() throws UnexpectedDocumentStructureException;
@@ -1151,6 +1160,10 @@ public abstract class DocNode implements Map<String, Object>, Document<Object> {
         return new PlainJavaObjectAdapter(this.toMap().with(otherDocNode.toMap()));
     }
 
+    public DocNode with(String key, Object value) {
+        return new PlainJavaObjectAdapter(this.toMap().with(key, value));
+    }
+    
     public DocNode without(String... attrs) {
         Set<String> attrsSet = new HashSet<>(Arrays.asList(attrs));
 
diff --git a/src/main/java/com/floragunn/codova/documents/DocReader.java b/src/main/java/com/floragunn/codova/documents/DocReader.java
index d2072072e2984e636a859c29842bad547c038902..0d3cf310652330b133d629f41812bfbc4b6f2d2e 100644
--- a/src/main/java/com/floragunn/codova/documents/DocReader.java
+++ b/src/main/java/com/floragunn/codova/documents/DocReader.java
@@ -216,10 +216,8 @@ public class DocReader {
 
                 container.put(attributePath[attributePath.length - 1], newNode);
             } else {
-                if (handleDottedAttributeNamesAsPathsFromDepth != -1 && handleDottedAttributeNamesAsPathsFromDepth <= depth
-                        && map.containsKey(currentAttributeName)) {
-                    throw new JsonParseException(parser, "Badly nested nodes: The key " + currentAttributeName
-                            + " is already defined as a non-map object: " + map.get(currentAttributeName));
+                if (map.containsKey(currentAttributeName)) {
+                    throw new JsonParseException(parser, "The attribute '" + currentAttributeName + "' is defined more than once");
                 }
 
                 map.put(currentAttributeName, newNode);
diff --git a/src/main/java/com/floragunn/codova/documents/DocUpdateException.java b/src/main/java/com/floragunn/codova/documents/DocUpdateException.java
new file mode 100644
index 0000000000000000000000000000000000000000..451e4683b61782244faf4ef5dc889c72bbb3cc64
--- /dev/null
+++ b/src/main/java/com/floragunn/codova/documents/DocUpdateException.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2022 floragunn GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.floragunn.codova.documents;
+
+public class DocUpdateException extends Exception {
+
+    private static final long serialVersionUID = -181998416660817945L;
+
+    public DocUpdateException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public DocUpdateException(String message) {
+        super(message);
+    }
+}
diff --git a/src/main/java/com/floragunn/codova/documents/DocumentParseException.java b/src/main/java/com/floragunn/codova/documents/DocumentParseException.java
index 59634cf0bd611deba1f94fec651ac4fb6dd01577..9b523a98dc82f89664ec6adeb88d8946c5b31cf7 100644
--- a/src/main/java/com/floragunn/codova/documents/DocumentParseException.java
+++ b/src/main/java/com/floragunn/codova/documents/DocumentParseException.java
@@ -1,3 +1,20 @@
+/*
+ * Copyright 2022 floragunn GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
 package com.floragunn.codova.documents;
 
 import java.util.LinkedHashMap;
diff --git a/src/main/java/com/floragunn/codova/documents/patch/DocPatch.java b/src/main/java/com/floragunn/codova/documents/patch/DocPatch.java
index 40d164676d83940357bf7dd2f76bfad16ed3930d..d941ffebd0c3ac03f1e40ddaba18f433bb1cbbba 100644
--- a/src/main/java/com/floragunn/codova/documents/patch/DocPatch.java
+++ b/src/main/java/com/floragunn/codova/documents/patch/DocPatch.java
@@ -18,6 +18,7 @@
 package com.floragunn.codova.documents.patch;
 
 import com.floragunn.codova.documents.DocNode;
+import com.floragunn.codova.documents.DocUpdateException;
 import com.floragunn.codova.documents.Format;
 import com.floragunn.codova.documents.Document;
 import com.floragunn.codova.documents.UnparsedDocument;
@@ -25,13 +26,15 @@ import com.floragunn.codova.validation.ConfigValidationException;
 import com.floragunn.codova.validation.errors.ValidationError;
 
 public interface DocPatch extends Document<Object> {
-    DocNode apply(DocNode targetDocument);
+    DocNode apply(DocNode targetDocument) throws DocUpdateException;
 
     String getMediaType();
 
     static DocPatch parse(String contentType, String content) throws ConfigValidationException {
         if (contentType.equalsIgnoreCase(MergePatch.MEDIA_TYPE)) {
             return new MergePatch(DocNode.parse(Format.JSON).from(content));
+        } else if (contentType.equalsIgnoreCase(JsonPatch.MEDIA_TYPE)) {
+            return new JsonPatch(DocNode.parse(Format.JSON).from(content));
         } else if (contentType.equalsIgnoreCase(JsonPathPatch.MEDIA_TYPE)) {
             return new JsonPathPatch(DocNode.parse(Format.JSON).from(content));
         } else if (contentType.equalsIgnoreCase(SimplePathPatch.MEDIA_TYPE)) {
@@ -44,10 +47,12 @@ public interface DocPatch extends Document<Object> {
     static DocPatch parse(UnparsedDocument<?> unparsedDoc) throws ConfigValidationException {
         if (unparsedDoc.getMediaType().equalsIgnoreCase(MergePatch.MEDIA_TYPE)) {
             return new MergePatch(DocNode.parse(unparsedDoc));
+        } else if (unparsedDoc.getMediaType().equalsIgnoreCase(JsonPatch.MEDIA_TYPE)) {
+            return new JsonPatch(DocNode.parse(unparsedDoc));
         } else if (unparsedDoc.getMediaType().equalsIgnoreCase(JsonPathPatch.MEDIA_TYPE)) {
             return new JsonPathPatch(DocNode.parse(unparsedDoc));
         } else if (unparsedDoc.getMediaType().equalsIgnoreCase(SimplePathPatch.MEDIA_TYPE)) {
-            return new SimplePathPatch(DocNode.parse(unparsedDoc));  
+            return new SimplePathPatch(DocNode.parse(unparsedDoc));
         } else {
             throw new ConfigValidationException(new ValidationError(null, "Unsupported patch type: " + unparsedDoc.getMediaType()));
         }
@@ -58,6 +63,8 @@ public interface DocPatch extends Document<Object> {
 
         if (contentType.equalsIgnoreCase(MergePatch.MEDIA_TYPE)) {
             return new MergePatch(docNode.getAsNode("content"));
+        } else if (contentType.equalsIgnoreCase(JsonPatch.MEDIA_TYPE)) {
+            return new JsonPatch(docNode.getAsNode("content"));
         } else if (contentType.equalsIgnoreCase(JsonPathPatch.MEDIA_TYPE)) {
             return new JsonPathPatch(docNode.getAsNode("content"));
         } else if (contentType.equalsIgnoreCase(SimplePathPatch.MEDIA_TYPE)) {
diff --git a/src/main/java/com/floragunn/codova/documents/patch/JsonPatch.java b/src/main/java/com/floragunn/codova/documents/patch/JsonPatch.java
new file mode 100644
index 0000000000000000000000000000000000000000..9e63245b378163e075d63e980efdde733cd1012c
--- /dev/null
+++ b/src/main/java/com/floragunn/codova/documents/patch/JsonPatch.java
@@ -0,0 +1,449 @@
+/*
+ * Copyright 2022 floragunn GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.floragunn.codova.documents.patch;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import com.floragunn.codova.documents.DocNode;
+import com.floragunn.codova.documents.DocUpdateException;
+import com.floragunn.codova.documents.Document;
+import com.floragunn.codova.documents.pointer.JsonPointer;
+import com.floragunn.codova.documents.pointer.PointerEvaluationException;
+import com.floragunn.codova.validation.ConfigValidationException;
+import com.floragunn.codova.validation.ValidatingDocNode;
+import com.floragunn.codova.validation.ValidationErrors;
+import com.floragunn.codova.validation.errors.InvalidAttributeValue;
+import com.floragunn.fluent.collections.ImmutableList;
+import com.floragunn.fluent.collections.OrderedImmutableMap;
+
+public class JsonPatch implements DocPatch {
+    public static final String MEDIA_TYPE = "application/json-patch+json";
+
+    private ImmutableList<Operation> operations;
+
+    public JsonPatch(Operation... operations) {
+        this.operations = ImmutableList.ofArray(operations);
+    }
+
+    JsonPatch(ImmutableList<Operation> operations) {
+        this.operations = operations;
+    }
+
+    JsonPatch(DocNode source) throws ConfigValidationException {
+        if (source.isList()) {
+            ValidationErrors validationErrors = new ValidationErrors();
+
+            int i = 0;
+
+            ArrayList<Operation> operations = new ArrayList<>();
+
+            for (DocNode element : source.toListOfNodes()) {
+                try {
+                    operations.add(Operation.parse(element));
+                } catch (ConfigValidationException e) {
+                    validationErrors.add(String.valueOf(i), e);
+                }
+                i++;
+            }
+            validationErrors.throwExceptionForPresentErrors();
+            this.operations = ImmutableList.of(operations);
+        } else {
+            throw new ConfigValidationException(new InvalidAttributeValue(null, source, "An array of operations"));
+        }
+    }
+
+    @Override
+    public Object toBasicObject() {
+        return operations;
+    }
+
+    @Override
+    public DocNode apply(DocNode targetDocument) throws DocUpdateException {
+        Object document = createMutableCopy(targetDocument.toBasicObject());
+        int i = 0;
+
+        for (Operation operation : this.operations) {
+            try {
+                if (operation instanceof Operation.Replace && ((Operation.Replace) operation).pointer.isRoot()) {
+                    document = createMutableCopy(((Operation.Replace) operation).value);
+                } else if (!operation.apply(document)) {
+                    return targetDocument;
+                }
+            } catch (PointerEvaluationException e) {
+                throw new DocUpdateException("Error while applying operation " + i + ": " + e.getMessage(), e);
+            }
+
+            i++;
+        }
+
+        return DocNode.wrap(document);
+    }
+
+    @Override
+    public String getMediaType() {
+        return MEDIA_TYPE;
+    }
+
+    private static Object createMutableCopy(Object object) {
+        if (object instanceof Map) {
+            LinkedHashMap<String, Object> result = new LinkedHashMap<>(((Map<?, ?>) object).size());
+
+            for (Map.Entry<?, ?> entry : ((Map<?, ?>) object).entrySet()) {
+                result.put(String.valueOf(entry.getKey()), createMutableCopy(entry.getValue()));
+            }
+
+            return result;
+        } else if (object instanceof Collection) {
+            ArrayList<Object> result = new ArrayList<>(((Collection<?>) object).size());
+
+            for (Object element : ((Collection<?>) object)) {
+                result.add(createMutableCopy(element));
+            }
+
+            return result;
+        } else {
+            return object;
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = 1;
+        result = prime * result + ((operations == null) ? 0 : operations.hashCode());
+        return result;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof JsonPatch)) {
+            return false;
+        }
+        JsonPatch other = (JsonPatch) obj;
+        if (operations == null) {
+            if (other.operations != null) {
+                return false;
+            }
+        } else if (!operations.equals(other.operations)) {
+            return false;
+        }
+        return true;
+    }
+
+    public boolean isEmpty() {
+        return operations.isEmpty();
+    }
+
+    public static JsonPatch fromDiff(DocNode source, DocNode target) {
+        if (source == null) {
+            source = DocNode.NULL;
+        }
+        if (target == null) {
+            target = DocNode.NULL;
+        }
+
+        if (source.isNull() && target.isNull()) {
+            return new JsonPatch();
+        }
+
+        if (source.isNull()) {
+            return new JsonPatch(new Operation.Replace(JsonPointer.ROOT, target.toBasicObject()));
+        }
+
+        if (target.isNull()) {
+            return new JsonPatch(new Operation.Replace(JsonPointer.ROOT, null));
+        }
+
+        if (source.isMap() && target.isMap()) {
+            ImmutableList.Builder<Operation> result = new ImmutableList.Builder<Operation>();
+            diff(JsonPointer.ROOT, source.toMap(), target.toMap(), result);
+            return new JsonPatch(result.build());
+        } else if (source.isList() && target.isList()) {
+            // TODO improve algorithm
+            return new JsonPatch(new Operation.Replace(JsonPointer.ROOT, target.toBasicObject()));
+        } else {
+            return new JsonPatch(new Operation.Replace(JsonPointer.ROOT, target.toBasicObject()));
+        }
+    }
+
+    private static void diff(JsonPointer pointer, Map<?, ?> source, Map<?, ?> target, ImmutableList.Builder<Operation> result) {
+        for (Map.Entry<?, ?> sourceEntry : source.entrySet()) {
+            Object key = sourceEntry.getKey();
+            Object sourceValue = sourceEntry.getValue();
+            Object targetValue = target.get(key);
+
+            if (sourceValue instanceof DocNode) {
+                sourceValue = ((DocNode) sourceValue).toBasicObject();
+            }
+
+            if (targetValue instanceof DocNode) {
+                targetValue = ((DocNode) targetValue).toBasicObject();
+            }
+
+            if (Objects.equals(sourceValue, targetValue)) {
+                continue;
+            }
+
+            if (targetValue == null) {
+                result.add(new Operation.Remove(pointer.with(key.toString())));
+            } else if (isScalar(targetValue)) {
+                result.add(new Operation.Replace(pointer.with(key.toString()), targetValue));
+            } else if (isScalar(sourceValue)) {
+                result.add(new Operation.Replace(pointer.with(key.toString()), targetValue));
+            } else if (sourceValue instanceof Map && targetValue instanceof Map) {
+                if (isAnyElementEqual((Map<?, ?>) sourceValue, (Map<?, ?>) targetValue)) {
+                    diff(pointer.with(key.toString()), (Map<?, ?>) sourceValue, (Map<?, ?>) targetValue, result);
+                } else {
+                    result.add(new Operation.Replace(pointer.with(key.toString()), targetValue));
+                }
+            } else if (sourceValue instanceof Collection && targetValue instanceof Collection) {
+                // TODO improve algorithm
+                result.add(new Operation.Replace(pointer.with(key.toString()), targetValue));
+            } else {
+                result.add(new Operation.Replace(pointer.with(key.toString()), targetValue));
+            }
+        }
+
+        for (Object key : target.keySet()) {
+            if (source.containsKey(key)) {
+                continue;
+            }
+
+            Object targetValue = target.get(key);
+            result.add(new Operation.Add(pointer.with(key.toString()), targetValue));
+        }
+    }
+
+    private static boolean isScalar(Object o) {
+        return !(o instanceof Map || o instanceof Collection);
+    }
+
+    private static boolean isAnyElementEqual(Map<?, ?> source, Map<?, ?> target) {
+        for (Map.Entry<?, ?> sourceEntry : source.entrySet()) {
+            Object key = sourceEntry.getKey();
+            Object sourceValue = sourceEntry.getValue();
+            Object targetValue = target.get(key);
+
+            if (sourceValue instanceof DocNode) {
+                sourceValue = ((DocNode) sourceValue).toBasicObject();
+            }
+
+            if (targetValue instanceof DocNode) {
+                targetValue = ((DocNode) targetValue).toBasicObject();
+            }
+
+            if (sourceValue instanceof Map && targetValue instanceof Map) {
+                if (isAnyElementEqual((Map<?, ?>) sourceValue, (Map<?, ?>) targetValue)) {
+                    return true;
+                }
+            } else if (Objects.equals(sourceValue, targetValue)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public static interface Operation {
+
+        boolean apply(Object document) throws PointerEvaluationException;
+
+        static Operation parse(DocNode docNode) throws ConfigValidationException {
+            ValidationErrors validationErrors = new ValidationErrors();
+            ValidatingDocNode vNode = new ValidatingDocNode(docNode, validationErrors);
+
+            String operation = vNode.get("op").required().asString();
+            validationErrors.throwExceptionForPresentErrors();
+
+            switch (operation.toLowerCase()) {
+            case "add":
+                return new Add(vNode);
+            case "remove":
+                return new Remove(vNode);
+            case "replace":
+                return new Replace(vNode);
+            case "move":
+                return new Move(vNode);
+            case "copy":
+                return new Copy(vNode);
+            case "test":
+                return new Test(vNode);
+            default:
+                throw new ConfigValidationException(new InvalidAttributeValue("op", operation, "add|remove|replace|move|test"));
+            }
+        }
+
+        public static class Add implements Operation, Document<Add> {
+            private final JsonPointer pointer;
+            private final Object value;
+
+            Add(ValidatingDocNode vNode) throws ConfigValidationException {
+                this.pointer = vNode.get("path").required().byString(JsonPointer::parse);
+                this.value = vNode.get("value").required().asAnything();
+                vNode.throwExceptionForPresentErrors();
+            }
+
+            Add(JsonPointer pointer, Object value) {
+                this.pointer = pointer;
+                this.value = value;
+            }
+
+            @Override
+            public Object toBasicObject() {
+                return OrderedImmutableMap.of("op", "add", "path", pointer.toString(), "value", value);
+            }
+
+            @Override
+            public boolean apply(Object document) throws PointerEvaluationException {
+                this.pointer.addAtPointedValue(document, value);
+                return true;
+            }
+        }
+
+        public static class Remove implements Operation, Document<Remove> {
+            private final JsonPointer pointer;
+
+            Remove(ValidatingDocNode vNode) throws ConfigValidationException {
+                this.pointer = vNode.get("path").required().byString(JsonPointer::parse);
+                vNode.throwExceptionForPresentErrors();
+            }
+
+            Remove(JsonPointer pointer) {
+                this.pointer = pointer;
+            }
+
+            @Override
+            public Object toBasicObject() {
+                return OrderedImmutableMap.of("op", "remove", "path", pointer.toString());
+            }
+
+            @Override
+            public boolean apply(Object document) throws PointerEvaluationException {
+                this.pointer.removePointedValue(document);
+                return true;
+            }
+        }
+
+        public static class Replace implements Operation, Document<Replace> {
+            private final JsonPointer pointer;
+            private final Object value;
+
+            Replace(ValidatingDocNode vNode) throws ConfigValidationException {
+                this.pointer = vNode.get("path").required().byString(JsonPointer::parse);
+                this.value = vNode.get("value").required().asAnything();
+                vNode.throwExceptionForPresentErrors();
+            }
+
+            Replace(JsonPointer pointer, Object value) {
+                this.pointer = pointer;
+                this.value = value;
+            }
+
+            @Override
+            public Object toBasicObject() {
+                return OrderedImmutableMap.of("op", "replace", "path", pointer.toString(), "value", value);
+            }
+
+            @Override
+            public boolean apply(Object document) throws PointerEvaluationException {
+                this.pointer.setPointedValue(document, value);
+                return true;
+            }
+        }
+
+        public static class Move implements Operation, Document<Move> {
+            private final JsonPointer from;
+            private final JsonPointer to;
+
+            Move(ValidatingDocNode vNode) throws ConfigValidationException {
+                this.from = vNode.get("from").required().byString(JsonPointer::parse);
+                this.to = vNode.get("path").required().byString(JsonPointer::parse);
+                vNode.throwExceptionForPresentErrors();
+            }
+
+            @Override
+            public Object toBasicObject() {
+                return OrderedImmutableMap.of("op", "move", "from", from.toString(), "path", to.toString());
+            }
+
+            @Override
+            public boolean apply(Object document) throws PointerEvaluationException {
+                Object value = this.from.removePointedValue(document);
+                this.to.addAtPointedValue(document, value);
+                return true;
+            }
+        }
+
+        public static class Copy implements Operation, Document<Copy> {
+            private final JsonPointer from;
+            private final JsonPointer to;
+
+            Copy(ValidatingDocNode vNode) throws ConfigValidationException {
+                this.from = vNode.get("from").required().byString(JsonPointer::parse);
+                this.to = vNode.get("path").required().byString(JsonPointer::parse);
+                vNode.throwExceptionForPresentErrors();
+            }
+
+            @Override
+            public Object toBasicObject() {
+                return OrderedImmutableMap.of("op", "copy", "from", from.toString(), "path", to.toString());
+            }
+
+            @Override
+            public boolean apply(Object document) throws PointerEvaluationException {
+                Object value = this.from.getPointedValue(document);
+                this.to.addAtPointedValue(document, createMutableCopy(value));
+                return true;
+            }
+        }
+
+        public static class Test implements Operation, Document<Test> {
+            private final JsonPointer pointer;
+            private final Object value;
+
+            Test(ValidatingDocNode vNode) throws ConfigValidationException {
+                this.pointer = vNode.get("path").required().byString(JsonPointer::parse);
+                this.value = vNode.get("value").required().asAnything();
+                vNode.throwExceptionForPresentErrors();
+            }
+
+            @Override
+            public Object toBasicObject() {
+                return OrderedImmutableMap.of("op", "test", "path", pointer.toString(), "value", value);
+            }
+
+            @Override
+            public boolean apply(Object document) throws PointerEvaluationException {
+                Object currentValue = this.pointer.getPointedValue(document);
+
+                if (Objects.equals(this.value, currentValue)) {
+                    return true;
+                } else {
+                    return false;
+                }
+            }
+        }
+    }
+}
diff --git a/src/main/java/com/floragunn/codova/documents/patch/PatchableDocument.java b/src/main/java/com/floragunn/codova/documents/patch/PatchableDocument.java
index d33f3f11d046f3f49cdef089b988ef138dae6299..53dc26224afc86d5b78ff63ca8626c5765a92006 100644
--- a/src/main/java/com/floragunn/codova/documents/patch/PatchableDocument.java
+++ b/src/main/java/com/floragunn/codova/documents/patch/PatchableDocument.java
@@ -18,16 +18,17 @@
 package com.floragunn.codova.documents.patch;
 
 import com.floragunn.codova.documents.DocNode;
+import com.floragunn.codova.documents.DocUpdateException;
 import com.floragunn.codova.documents.Document;
 import com.floragunn.codova.documents.Parser;
 import com.floragunn.codova.validation.ConfigValidationException;
 
 public interface PatchableDocument<T> extends Document<T> {
-    default T patch(DocPatch patch, Parser.Context context) throws ConfigValidationException {
+    default T patch(DocPatch patch, Parser.Context context) throws ConfigValidationException, DocUpdateException {
         DocNode patchedDocNode = patch.apply(this.toDocNode());
-        
+
         return parseI(patchedDocNode, context);
     }
-    
+
     T parseI(DocNode docNode, Parser.Context context) throws ConfigValidationException;
 }
diff --git a/src/main/java/com/floragunn/codova/documents/pointer/DocPointer.java b/src/main/java/com/floragunn/codova/documents/pointer/DocPointer.java
new file mode 100644
index 0000000000000000000000000000000000000000..7b910308336df003c16969fbcec5643c78645d3a
--- /dev/null
+++ b/src/main/java/com/floragunn/codova/documents/pointer/DocPointer.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022 floragunn GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.floragunn.codova.documents.pointer;
+
+import com.floragunn.codova.documents.DocNode;
+
+public interface DocPointer {
+    default DocNode getPointedValueAsNode(DocNode docNode) throws PointerEvaluationException {
+        return DocNode.wrap(getPointedValue(docNode.toBasicObject()));
+    }
+
+    default Object getPointedValue(DocNode docNode) throws PointerEvaluationException {
+        return getPointedValue(docNode.toBasicObject());
+    }
+
+    Object getPointedValue(Object objectTreeNode) throws PointerEvaluationException;
+
+    void setPointedValue(Object objectTreeNode, Object newValue) throws PointerEvaluationException;
+
+    void addAtPointedValue(Object objectTreeNode, Object newValue) throws PointerEvaluationException;
+
+    Object removePointedValue(Object objectTreeNode) throws PointerEvaluationException;
+
+    DocPointer getParent();
+}
diff --git a/src/main/java/com/floragunn/codova/documents/pointer/JsonPointer.java b/src/main/java/com/floragunn/codova/documents/pointer/JsonPointer.java
new file mode 100644
index 0000000000000000000000000000000000000000..052e618d741dc1f0c1f79b7e6528a9db7569efb0
--- /dev/null
+++ b/src/main/java/com/floragunn/codova/documents/pointer/JsonPointer.java
@@ -0,0 +1,348 @@
+/*
+ * Copyright 2022 floragunn GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.floragunn.codova.documents.pointer;
+
+import java.util.List;
+import java.util.Map;
+
+import com.floragunn.codova.validation.ConfigValidationException;
+import com.floragunn.codova.validation.errors.ValidationError;
+import com.floragunn.fluent.collections.ImmutableList;
+
+public class JsonPointer implements DocPointer {
+    public static final JsonPointer ROOT = new JsonPointer(ImmutableList.empty(), "");
+
+    private final ImmutableList<ReferenceToken> tokens;
+    private final String source;
+    private JsonPointer parent;
+
+    public static JsonPointer parse(String jsonPointer) throws ConfigValidationException {
+        if (jsonPointer.length() == 0) {
+            return ROOT;
+        }
+
+        if (jsonPointer.charAt(0) != '/') {
+            throw new ConfigValidationException(new ValidationError(null, "Invalid character at first position"));
+        }
+
+        String[] tokenStrings = jsonPointer.substring(1).split("/");
+        ImmutableList.Builder<ReferenceToken> tokens = new ImmutableList.Builder<>(tokenStrings.length);
+
+        for (int i = 0; i < tokenStrings.length; i++) {
+            tokens.add(new ReferenceToken(tokenStrings[i]));
+        }
+
+        return new JsonPointer(tokens.build(), jsonPointer);
+    }
+
+    JsonPointer(ImmutableList<ReferenceToken> tokens, String source) {
+        this.tokens = tokens;
+        this.source = source;
+
+        if (tokens.size() == 1) {
+            this.parent = ROOT;
+        }
+    }
+
+    @Override
+    public Object getPointedValue(Object objectTreeNode) throws PointerEvaluationException {
+        int tokenSize = this.tokens.size();
+
+        if (tokenSize == 0) {
+            return objectTreeNode;
+        }
+
+        Object currentNode = objectTreeNode;
+
+        for (int i = 0; i < tokenSize; i++) {
+            ReferenceToken token = this.tokens.get(i);
+
+            if (currentNode instanceof List) {
+                if (token.index == ReferenceToken.NON_NUMERIC) {
+                    throw new PointerEvaluationException(token.content + " is not a valid array index");
+                } else if (token.index == ReferenceToken.LAST) {
+                    throw new PointerEvaluationException(token.content + " cannot be used for reading a value from an index");
+                } else {
+                    List<?> list = (List<?>) currentNode;
+                    if (list.size() > token.index) {
+                        currentNode = list.get(token.index);
+                    } else {
+                        throw new PointerEvaluationException(token.index + " out of bounds for array of size " + list.size());
+                    }
+                }
+            } else if (currentNode instanceof Map) {
+                currentNode = ((Map<?, ?>) currentNode).get(token.content);
+            } else {
+                throw new PointerEvaluationException(token.content + " points to a scalar value. Expected array or object.");
+            }
+        }
+
+        return currentNode;
+    }
+
+    @Override
+    public void setPointedValue(Object objectTreeNode, Object newValue) throws PointerEvaluationException {
+        Object containerNode = getParent().getPointedValue(objectTreeNode);
+        ReferenceToken token = this.tokens.get(this.tokens.size() - 1);
+
+        if (containerNode instanceof List) {
+            @SuppressWarnings("unchecked")
+            List<Object> list = (List<Object>) containerNode;
+
+            if (token.index == ReferenceToken.NON_NUMERIC) {
+                throw new PointerEvaluationException(token.content + " is not a valid array index");
+            } else if (token.index == ReferenceToken.LAST) {
+                list.add(newValue);
+            } else if (token.index < list.size()) {
+                list.set(token.index, newValue);
+            }
+        } else if (containerNode instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> map = (Map<String, Object>) containerNode;
+            map.put(token.content, newValue);
+        } else {
+            throw new PointerEvaluationException(token.content + " points to a scalar value. Expected array or object.");
+        }
+    }
+
+    @Override
+    public void addAtPointedValue(Object objectTreeNode, Object newValue) throws PointerEvaluationException {
+        Object containerNode = getParent().getPointedValue(objectTreeNode);
+        ReferenceToken token = this.tokens.get(this.tokens.size() - 1);
+
+        if (containerNode instanceof List) {
+            @SuppressWarnings("unchecked")
+            List<Object> list = (List<Object>) containerNode;
+
+            if (token.index == ReferenceToken.NON_NUMERIC) {
+                throw new PointerEvaluationException(token.content + " is not a valid array index");
+            } else if (token.index == ReferenceToken.LAST) {
+                list.add(newValue);
+            } else if (token.index < list.size()) {
+                list.add(token.index, newValue);
+            }
+        } else if (containerNode instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> map = (Map<String, Object>) containerNode;
+            map.put(token.content, newValue);
+        } else {
+            throw new PointerEvaluationException(token.content + " points to a scalar value. Expected array or object.");
+        }
+    }
+
+    @Override
+    public Object removePointedValue(Object objectTreeNode) throws PointerEvaluationException {
+        Object containerNode = getParent().getPointedValue(objectTreeNode);
+
+        ReferenceToken token = this.tokens.get(this.tokens.size() - 1);
+
+        if (containerNode instanceof List) {
+            @SuppressWarnings("unchecked")
+            List<Object> list = (List<Object>) containerNode;
+
+            if (token.index == ReferenceToken.NON_NUMERIC) {
+                return null;
+            } else if (token.index == ReferenceToken.LAST) {
+                return null;
+            } else if (token.index < list.size()) {
+                return list.remove(token.index);
+            } else {
+                return null;
+            }
+        } else if (containerNode instanceof Map) {
+            @SuppressWarnings("unchecked")
+            Map<String, Object> map = (Map<String, Object>) containerNode;
+            return map.remove(token.content);
+        } else {
+            throw new PointerEvaluationException(token.content + " points to a scalar value. Expected array or object.");
+        }
+    }
+
+    public JsonPointer with(String referenceToken) {
+        String escaped = ReferenceToken.escape(referenceToken);
+        return new JsonPointer(tokens.with(new ReferenceToken(escaped)), this.source + "/" + escaped);
+    }
+
+    public JsonPointer with(int referenceToken) {
+        return new JsonPointer(tokens.with(new ReferenceToken(referenceToken)), this.source + "/" + referenceToken);
+    }
+
+    public boolean isRoot() {
+        return tokens.isEmpty();
+    }
+
+    static class ReferenceToken {
+        static final int LAST = -1;
+        static final int NON_NUMERIC = -2;
+
+        private final String source;
+        private final String content;
+        private final int index;
+
+        ReferenceToken(String tokenString) {
+            this.source = tokenString;
+            this.content = unescapeTokenString(tokenString);
+            this.index = parseToIndex(tokenString);
+        }
+
+        ReferenceToken(int index) {
+            this.source = this.content = index == LAST ? "-" : String.valueOf(index);
+            this.index = index;
+        }
+
+        static String escape(String tokenString) {
+            if (!needsEscaping(tokenString)) {
+                return tokenString;
+            }
+
+            StringBuilder result = new StringBuilder(tokenString.length() + 10);
+
+            for (int i = 0; i < tokenString.length(); i++) {
+                char c = tokenString.charAt(i);
+                switch (c) {
+                case '~':
+                    result.append("~0");
+                    break;
+                case '/':
+                    result.append("~1");
+                    break;
+                default:
+                    result.append(c);
+                }
+            }
+
+            return result.toString();
+        }
+
+        static boolean needsEscaping(String tokenString) {
+            int length = tokenString.length();
+
+            for (int i = 0; i < length; i++) {
+                switch (tokenString.charAt(i)) {
+                case '~':
+                case '/':
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
+        private static String unescapeTokenString(String tokenString) {
+            if (tokenString.indexOf('~') == -1) {
+                return tokenString;
+            } else {
+                return tokenString.replace("~1", "/").replace("~0", "~");
+            }
+        }
+
+        @Override
+        public String toString() {
+            return source;
+        }
+
+        private static int parseToIndex(String tokenString) {
+            if (tokenString.equals("-")) {
+                return LAST;
+            } else if (isNumeric(tokenString)) {
+                return Integer.parseInt(tokenString);
+            } else {
+                return NON_NUMERIC;
+            }
+        }
+
+        private static boolean isNumeric(String tokenString) {
+            int length = tokenString.length();
+
+            if (length == 0) {
+                return false;
+            }
+
+            for (int i = 0; i < length; i++) {
+                char c = tokenString.charAt(i);
+                if (!(c >= '0' && c <= '9')) {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+
+        @Override
+        public int hashCode() {
+            return content.hashCode();
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (!(obj instanceof ReferenceToken)) {
+                return false;
+            }
+            ReferenceToken other = (ReferenceToken) obj;
+
+            return this.content.equals(other.content);
+        }
+
+    }
+
+    @Override
+    public String toString() {
+        return source;
+    }
+
+    @Override
+    public DocPointer getParent() {
+        JsonPointer parent = this.parent;
+
+        if (parent == null) {
+            if (tokens.size() == 0) {
+                return null;
+            } else if (tokens.size() == 1) {
+                return ROOT;
+            } else {
+                this.parent = parent = new JsonPointer((ImmutableList<ReferenceToken>) tokens.subList(0, tokens.size() - 1),
+                        source.substring(0, this.source.lastIndexOf('/')));
+                return parent;
+            }
+        } else {
+            return parent;
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return tokens.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof JsonPointer)) {
+            return false;
+        }
+        JsonPointer other = (JsonPointer) obj;
+
+        return this.tokens.equals(other.tokens);
+    }
+
+}
diff --git a/src/main/java/com/floragunn/codova/documents/pointer/PointerEvaluationException.java b/src/main/java/com/floragunn/codova/documents/pointer/PointerEvaluationException.java
new file mode 100644
index 0000000000000000000000000000000000000000..b8b133d92364e679603c19c7446c5fdf5156c7f3
--- /dev/null
+++ b/src/main/java/com/floragunn/codova/documents/pointer/PointerEvaluationException.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2022 floragunn GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.floragunn.codova.documents.pointer;
+
+public class PointerEvaluationException extends Exception {
+
+    private static final long serialVersionUID = 770109146930861850L;
+
+    public PointerEvaluationException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public PointerEvaluationException(String message) {
+        super(message);
+    }
+
+}
diff --git a/src/main/java/com/floragunn/codova/validation/ValidatingDocNode.java b/src/main/java/com/floragunn/codova/validation/ValidatingDocNode.java
index 286475524f70d4a824641f827925cb440fab9053..90cf1ce7ac1668fc4b15797dd46ca41e089b31c8 100644
--- a/src/main/java/com/floragunn/codova/validation/ValidatingDocNode.java
+++ b/src/main/java/com/floragunn/codova/validation/ValidatingDocNode.java
@@ -276,6 +276,10 @@ public class ValidatingDocNode {
         return documentNode;
     }
 
+    public void throwExceptionForPresentErrors() throws ConfigValidationException {
+        validationErrors.throwExceptionForPresentErrors();
+    }
+
     public abstract class AbstractAttribute<T> {
         protected final String name;
         protected final String fullAttributePath;
diff --git a/src/test/java/com/floragunn/codova/documents/JsonPointerTest.java b/src/test/java/com/floragunn/codova/documents/JsonPointerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f9cb59dd4a0a91f4aaf8ba39ef4588b3b21e7f8e
--- /dev/null
+++ b/src/test/java/com/floragunn/codova/documents/JsonPointerTest.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2022 floragunn GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.floragunn.codova.documents;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.floragunn.codova.documents.pointer.JsonPointer;
+import com.floragunn.fluent.collections.ImmutableList;
+import com.floragunn.fluent.collections.OrderedImmutableMap;
+
+public class JsonPointerTest {
+    @Test
+    public void getPointedValue_root() throws Exception {
+        Object o = DocNode.of("a.a1", 1, "a.a2", 2).toDeepBasicObject();
+        Assert.assertEquals(o, JsonPointer.parse("").getPointedValue(o));
+    }
+
+    @Test
+    public void getPointedValue_topLevel() throws Exception {
+        Map<String, Object> o = OrderedImmutableMap.of("a", OrderedImmutableMap.of("a1", 1, "a2", 2), "b", 100);
+        Assert.assertEquals(o.get("a"), JsonPointer.parse("/a").getPointedValue(o));
+        Assert.assertEquals(o.get("b"), JsonPointer.parse("/b").getPointedValue(o));
+    }
+
+    @Test
+    public void getPointedValue_escape_code0() throws Exception {
+        Map<String, Object> o = OrderedImmutableMap.of("a", OrderedImmutableMap.of("~", 1, "a2", 2), "b", 100);
+        Assert.assertEquals(o.get("~"), JsonPointer.parse("/~0").getPointedValue(o));
+    }
+
+    @Test
+    public void getPointedValue_escape_code1() throws Exception {
+        Map<String, Object> o = OrderedImmutableMap.of("a", OrderedImmutableMap.of("/", 1, "a2", 2), "b", 100);
+        Assert.assertEquals(o.get("/"), JsonPointer.parse("/~1").getPointedValue(o));
+    }
+
+    @Test
+    public void getPointedValue_nested() throws Exception {
+        Map<String, Object> o = OrderedImmutableMap.of("a", OrderedImmutableMap.of("a1", 1, "a2", 2));
+        Assert.assertEquals(((Map<?, ?>) o.get("a")).get("a1"), JsonPointer.parse("/a/a1").getPointedValue(o));
+    }
+
+    @Test
+    public void getPointedValue_numericMapKey() throws Exception {
+        Map<String, Object> o = OrderedImmutableMap.of("a", OrderedImmutableMap.of("1", 1, "2", 2));
+        Assert.assertEquals(((Map<?, ?>) o.get("a")).get("1"), JsonPointer.parse("/a/1").getPointedValue(o));
+    }
+
+    @Test
+    public void getPointedValue_array() throws Exception {
+        Map<String, Object> o = OrderedImmutableMap.of("a", ImmutableList.of(1, 5, 9, 42));
+        Assert.assertEquals(((List<?>) o.get("a")).get(3), JsonPointer.parse("/a/3").getPointedValue(o));
+    }
+
+    @Test
+    public void getPointedValue_array_root() throws Exception {
+        List<Object> o = ImmutableList.of(1, 5, 9, 42);
+        Assert.assertEquals(o.get(3), JsonPointer.parse("/3").getPointedValue(o));
+    }
+
+    @Test
+    public void setPointedValue_topLevel() throws Exception {
+        OrderedImmutableMap<String, Object> base = OrderedImmutableMap.of("a", OrderedImmutableMap.of("a1", 1, "a2", 2));
+        Map<String, Object> subject = new HashMap<>(base);
+        OrderedImmutableMap<String, Object> reference = base.with("a", 5);
+        JsonPointer.parse("/a").setPointedValue(subject, 5);
+
+        Assert.assertEquals(reference, subject);
+    }
+
+    @Test
+    public void setPointedValue_nested() throws Exception {
+        Map<String, Object> subject = new LinkedHashMap<>(OrderedImmutableMap.of("a", new LinkedHashMap<>(OrderedImmutableMap.of("a1", 1, "a2", 2))));
+        Map<String, Object> reference = OrderedImmutableMap.of("a", OrderedImmutableMap.of("a1", 1, "a2", 5));
+        JsonPointer.parse("/a/a2").setPointedValue(subject, 5);
+
+        Assert.assertEquals(reference, subject);
+    }
+
+    @Test
+    public void addAtPointedValue_nested_array() throws Exception {
+        Map<String, Object> subject = new LinkedHashMap<>(OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 20, 30))));
+        Map<String, Object> reference = OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 20, 25, 30)));
+        JsonPointer.parse("/a/2").addAtPointedValue(subject, 25);
+
+        Assert.assertEquals(reference, subject);
+    }
+
+    @Test
+    public void addAtPointedValue_array_end() throws Exception {
+        Map<String, Object> subject = new LinkedHashMap<>(OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 20, 30))));
+        Map<String, Object> reference = OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 20, 30, 40)));
+        JsonPointer.parse("/a/-").addAtPointedValue(subject, 40);
+
+        Assert.assertEquals(reference, subject);
+    }
+
+    @Test
+    public void removePointedValue_nested_object() throws Exception {
+        Map<String, Object> subject = new LinkedHashMap<>(
+                OrderedImmutableMap.of("a", new LinkedHashMap<>(OrderedImmutableMap.of("a1", 10, "a2", 20))));
+        Map<String, Object> reference = OrderedImmutableMap.of("a", OrderedImmutableMap.of("a1", 10));
+        JsonPointer.parse("/a/a2").removePointedValue(subject);
+
+        Assert.assertEquals(reference, subject);
+    }
+
+    @Test
+    public void removePointedValue_nested_array() throws Exception {
+        Map<String, Object> subject = new LinkedHashMap<>(OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 20, 30))));
+        Map<String, Object> reference = OrderedImmutableMap.of("a", new ArrayList<>(Arrays.asList(10, 30)));
+        JsonPointer.parse("/a/1").removePointedValue(subject);
+
+        Assert.assertEquals(reference, subject);
+    }
+
+    @Test
+    public void getParent() throws Exception {
+        JsonPointer pointer = JsonPointer.parse("/a/b/c");
+        Assert.assertEquals(JsonPointer.parse("/a/b"), pointer.getParent());
+        Assert.assertEquals(JsonPointer.parse("/a"), pointer.getParent().getParent());
+        Assert.assertEquals(JsonPointer.parse(""), pointer.getParent().getParent().getParent());
+    }
+
+}
diff --git a/src/test/java/com/floragunn/codova/documents/patch/JsonPatchTest.java b/src/test/java/com/floragunn/codova/documents/patch/JsonPatchTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..802514cf9043b45e54eff333f14eda2bbcf7e536
--- /dev/null
+++ b/src/test/java/com/floragunn/codova/documents/patch/JsonPatchTest.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2022 floragunn GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.floragunn.codova.documents.patch;
+
+import java.util.Arrays;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.floragunn.codova.documents.DocNode;
+
+public class JsonPatchTest {
+    @Test
+    public void add() throws Exception {
+        DocNode base = DocNode.of("a.a1", 10, "a.a2", 20);
+        DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "add", "path", "/a/a3", "value", 30))).apply(base);
+        DocNode reference = DocNode.of("a.a1", 10, "a.a2", 20, "a.a3", 30);
+        Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void add_array() throws Exception {
+        DocNode base = DocNode.of("a", Arrays.asList(10, 20, 30));
+        DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "add", "path", "/a/2", "value", 25))).apply(base);
+        DocNode reference = DocNode.of("a", Arrays.asList(10, 20, 25, 30));
+        Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void remove() throws Exception {
+        DocNode base = DocNode.of("a.a1", 10, "a.a2", 20);
+        DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "remove", "path", "/a/a2"))).apply(base);
+        DocNode reference = DocNode.of("a.a1", 10);
+        Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void remove_array() throws Exception {
+        DocNode base = DocNode.of("a", Arrays.asList(10, 20, 30));
+        DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "remove", "path", "/a/1"))).apply(base);
+        DocNode reference = DocNode.of("a", Arrays.asList(10, 30));
+        Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void replace() throws Exception {
+        DocNode base = DocNode.of("a.a1", 10, "a.a2", 20);
+        DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "replace", "path", "/a/a2", "value", 25))).apply(base);
+        DocNode reference = DocNode.of("a.a1", 10, "a.a2", 25);
+        Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void replace_array() throws Exception {
+        DocNode base = DocNode.of("a", Arrays.asList(10, 20, 30));
+        DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "replace", "path", "/a/1", "value", 25))).apply(base);
+        DocNode reference = DocNode.of("a", Arrays.asList(10, 25, 30));
+        Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void replace_root() throws Exception {
+        DocNode base = DocNode.of("a.a1", 10, "a.a2", 20);
+        DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "replace", "path", "", "value", 25))).apply(base);
+        DocNode reference = DocNode.wrap(25);
+        Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void copy() throws Exception {
+        DocNode base = DocNode.of("a.a1", 10, "a.a2", 20);
+        DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "copy", "path", "/a/b", "from", "/a"))).apply(base);
+        DocNode reference = DocNode.of("a.a1", 10, "a.a2", 20, "a.b.a1", 10, "a.b.a2", 20);
+        Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void move() throws Exception {
+        DocNode base = DocNode.of("a.a1", 10, "a.a2", 20);
+        DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "move", "path", "/a/a3", "from", "/a/a2"))).apply(base);
+        DocNode reference = DocNode.of("a.a1", 10, "a.a3", 20);
+        Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void test_success() throws Exception {
+        DocNode base = DocNode.of("a.a1", 10, "a.a2", 20);
+        DocNode subject = new JsonPatch(
+                DocNode.array(DocNode.of("op", "test", "path", "/a/a2", "value", 20), DocNode.of("op", "add", "path", "/a/a3", "value", 30)))
+                        .apply(base);
+        DocNode reference = DocNode.of("a.a1", 10, "a.a2", 20, "a.a3", 30);
+        Assert.assertEquals(reference.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void test_fail() throws Exception {
+        DocNode base = DocNode.of("a.a1", 10, "a.a2", 20);
+        DocNode subject = new JsonPatch(
+                DocNode.array(DocNode.of("op", "test", "path", "/a/a2", "value", 21), DocNode.of("op", "add", "path", "/a/a3", "value", 30)))
+                        .apply(base);
+        Assert.assertEquals(base.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void test_fail_atomic() throws Exception {
+        DocNode base = DocNode.of("a.a1", 10, "a.a2", 20);
+        DocNode subject = new JsonPatch(DocNode.array(DocNode.of("op", "add", "path", "/a/a5", "value", 50),
+                DocNode.of("op", "test", "path", "/a/a2", "value", 21), DocNode.of("op", "add", "path", "/a/a3", "value", 30))).apply(base);
+        Assert.assertEquals(base.toDeepBasicObject(), subject.toDeepBasicObject());
+    }
+
+    @Test
+    public void diff_equal_simple() throws Exception {
+        DocNode d1 = DocNode.of("a", 1);
+        DocNode d2 = DocNode.of("a", 1);
+
+        JsonPatch diff = JsonPatch.fromDiff(d1, d2);
+
+        Assert.assertTrue(diff.toJsonString(), diff.isEmpty());
+    }
+
+    @Test
+    public void diff_equal_moreComplex() throws Exception {
+        DocNode d1 = DocNode.of("a", 1, "b", Arrays.asList(1, 2, 3), "c.c1", "C1", "c.c2", "C2");
+        DocNode d2 = DocNode.of("a", 1, "c.c1", "C1", "c.c2", "C2", "b", Arrays.asList(1, 2, 3));
+
+        JsonPatch diff = JsonPatch.fromDiff(d1, d2);
+
+        Assert.assertTrue(diff.toJsonString(), diff.isEmpty());
+    }
+
+    @Test
+    public void diff_replace_simple() throws Exception {
+        DocNode d1 = DocNode.of("a", 1);
+        DocNode d2 = DocNode.of("a", 2);
+
+        JsonPatch diff = JsonPatch.fromDiff(d1, d2);
+
+        Assert.assertFalse(diff.toJsonString(), diff.isEmpty());
+        Assert.assertEquals(d2.toBasicObject(), diff.apply(d1).toBasicObject());
+    }
+
+    @Test
+    public void diff_replace_moreComplex() throws Exception {
+        DocNode d1 = DocNode.of("a", 1, "b", Arrays.asList(1, 2, 3), "c.c1", "C1", "c.c2", "C2");
+        DocNode d2 = DocNode.of("a", 1, "c.c1", "C1", "c.c2", "CX", "b", Arrays.asList(1, 2, 99, 3));
+
+        JsonPatch diff = JsonPatch.fromDiff(d1, d2);
+
+        Assert.assertFalse(diff.toJsonString(), diff.isEmpty());
+        Assert.assertEquals(d2.toDeepBasicObject(), diff.apply(d1).toDeepBasicObject());
+    }
+
+    @Test
+    public void diff_replace_depth_3() throws Exception {
+        DocNode d1 = DocNode.of("a", 1, "b", 2, "c.c.c1", "C1", "c.c.c2", "C2");
+        DocNode d2 = DocNode.of("a", 1, "c.c.c1", "C1", "c.c.c2", "CX", "b", 2);
+
+        JsonPatch diff = JsonPatch.fromDiff(d1, d2);
+
+        System.out.println(diff.toJsonString());
+        Assert.assertFalse(diff.toJsonString(), diff.isEmpty());
+        Assert.assertEquals(d2.toDeepBasicObject(), diff.apply(d1).toDeepBasicObject());
+    }
+
+    @Test
+    public void diff_delete_simple() throws Exception {
+        DocNode d1 = DocNode.of("a", 1);
+        DocNode d2 = DocNode.EMPTY;
+
+        JsonPatch diff = JsonPatch.fromDiff(d1, d2);
+
+        Assert.assertFalse(diff.toJsonString(), diff.isEmpty());
+        Assert.assertEquals(d2.toBasicObject(), diff.apply(d1).toBasicObject());
+    }
+
+    @Test
+    public void diff_delete_moreComplex() throws Exception {
+        DocNode d1 = DocNode.of("a", 1, "b", Arrays.asList(1, 2, 3), "c.c1", "C1", "c.c2", "C2");
+        DocNode d2 = DocNode.of("a", 1, "c.c1", "C1", "b", Arrays.asList(1, 3));
+
+        JsonPatch diff = JsonPatch.fromDiff(d1, d2);
+
+        Assert.assertFalse(diff.toJsonString(), diff.isEmpty());
+        Assert.assertEquals(d2.toDeepBasicObject(), diff.apply(d1).toDeepBasicObject());
+    }
+
+    @Test
+    public void diff_add_simple() throws Exception {
+        DocNode d1 = DocNode.of("a", 1);
+        DocNode d2 = DocNode.of("a", 1, "b", 2);
+
+        JsonPatch diff = JsonPatch.fromDiff(d1, d2);
+
+        Assert.assertFalse(diff.toJsonString(), diff.isEmpty());
+        Assert.assertEquals(d2.toBasicObject(), diff.apply(d1).toBasicObject());
+    }
+
+    @Test
+    public void diff_add_moreComplex() throws Exception {
+        DocNode d1 = DocNode.of("a", 1);
+        DocNode d2 = DocNode.of("a", 1, "c.c1", "C1", "c.c2", "C2");
+
+        JsonPatch diff = JsonPatch.fromDiff(d1, d2);
+
+        Assert.assertFalse(diff.toJsonString(), diff.isEmpty());
+        Assert.assertEquals(d2.toDeepBasicObject(), diff.apply(d1).toDeepBasicObject());
+    }
+}