You can still use classes, just add  %JSONIGNOREINVALIDFIELD to ignore unknown properties:

Parameter %JSONIGNOREINVALIDFIELD As BOOLEAN = 1;

Here's how your example can look like:

Class dc.Item Extends (%RegisteredObject, %JSON.Adaptor)
{

Parameter %JSONIGNOREINVALIDFIELD As BOOLEAN = 1;

Property pureId As %Integer;

Property portalUrl As %VarString;

}

and the main class:

Class dc.Response Extends (%RegisteredObject, %JSON.Adaptor)
{

Parameter %JSONIGNOREINVALIDFIELD As BOOLEAN = 1;

Property items As list Of Item;

/// do ##class(dc.Response).Test()
ClassMethod Test()
{
	set json = ..Sample()
	set obj = ..%New()
	$$$TOE(sc, obj.%JSONImport(json))
	do obj.DisplayItems()
}

Method DisplayItems()
{
	for i=1:1:..items.Count() {
		set item = ..items.GetAt(i)
		zw item
	}
}

ClassMethod Sample() As %String [ CodeMode = expression ]
{
{
  "count": 0,
  "pageInformation": {
    "offset": 0,
    "size": 0
  },
  "items": [
    {
      "pureId": 0,
      "uuid": "196ab1c9-6e60-4000-88cb-4b1795761180",
      "createdBy": "string",
      "createdDate": "1970-01-01T00:00:00.000Z",
      "modifiedBy": "string",
      "modifiedDate": "1970-01-01T00:00:00.000Z",
      "portalUrl": "string",
      "prettyUrlIdentifiers": [
        "string"
      ],
      "previousUuids": [
        "string"
      ],
      "version": "string",
      "startDateAsResearcher": "1970-01-01",
      "affiliationNote": "string",
      "dateOfBirth": "1970-01-01",
      "employeeStartDate": "1970-01-01",
      "employeeEndDate": "1970-01-01",
      "externalPositions": [
        {
          "pureId": 0,
          "appointment": {
            "uri": "string",
            "term": {
              "en_GB": "Some text"
            }
          },
          "appointmentString": {
            "en_GB": "Some text"
          },
          "period": {
            "startDate": {
              "year": 0,
              "month": 1,
              "day": 1
            },
            "endDate": {
              "year": 0,
              "month": 1,
              "day": 1
            }
          },
          "externalOrganization": {
            "uuid": "196ab1c9-6e60-4000-8b89-29269178a480",
            "systemName": "string"
          }
        }
    ]
    }
	]
}.%ToJSON()
}

}

Something along these lines should work.

ClassMethod ToEnsStream(obj As %RegisteredObject, Output req As Ens.StreamContainer) As %Status
{
	#dim sc As %Status = $$$OK
	try {
		set stream = ##class(%Stream.GlobalCharacter).%New()
		if obj.%Extends("%JSON.Adaptor") {
			$$$TOE(sc, obj.%JSONExportToStream(.stream))
		} elseif obj.%Extends(##class(%DynamicAbstractObject).%ClassName(1)) {
			do obj.%ToJSON(.stream)
		} else {
			/// try %ZEN.Auxiliary.altJSONProvider:%ObjectToAET?
			throw ##class(%Exception.General).%New("<JSON>")
		}
		set req = ##class(Ens.StreamContainer).%New(stream)
	} catch ex {
		set sc = ex.AsStatus()
	}
	quit sc
}

Looks like a bug. Please check with WRC.

Simplified your example a bit:

Class DC.ValueOfThis Extends %RegisteredObject
{

/// do ##class(DC.ValueOfThis).Test()
ClassMethod Test()
{
	write $zv,!!
	set obj=..%New()
	do obj.Work()
	write $$$FormatText("classmethod: $this %1, ..Value() %2", $this, ..Value()),!
	do obj.Work()
}

Method Work()
{
	write $$$FormatText("method: $this %1, ..Value() %2", $this, ..Value()),!
}

ClassMethod Value()
{
	quit $this
}

}

Here's a sample zzdump custom function for DTL:

Class Utils.Functions Extends Ens.Util.FunctionSet
{

/// w ##class(Utils.Functions).ZZDUMP("abc")
ClassMethod ZZDUMP(var) As %String
{
	set id = $random(100000000000000000)
	while $d(^ISC.AsyncQueue(id)) {
		set id = $random(100000000000000000)
	}
	set str = ""
	
	try {	
		$$$TOE(sc, ##class(%Api.Atelier.v1).MonitorBeginCapture(id))
		if '$data(var) {
			write "<UNDEFINED>"
		} else {
			if '$isobject(var) {
				zzdump var
			} else {
				if var.%IsA(##class(%DynamicAbstractObject).%ClassName(1)) {
					zzdump var.%ToJSON()
				} elseif var.%IsA(##class(%Stream.Object).%ClassName(1)) {
					do var.Rewind()
					zzdump var.Read()
				} elseif var.%IsA(##class(EnsLib.HL7.Message).%ClassName(1)) {
					zzdump var.OutputToString()
				} else {
					// zzdump will output OREF string.
					zw var 
				}
			}
		}
		$$$TOE(sc, ##class(%Api.Atelier.v1).MonitorEndCapture(id))
	
		for i=1:1:^ISC.AsyncQueue(id,"cout","i") {
			set str = str _ ^ISC.AsyncQueue(id,"cout",i) _ $$$NL
		}
	} catch ex {
		do ##class(%Api.Atelier.v1).MonitorEndCapture(id)
	}
	kill ^ISC.AsyncQueue(id)
	quit str
}

}