The SOAP XML generated by WCF can be customized but end-to-end documentation describing how to do it is becoming difficult to find. This article puts together steps from several different sources -most written more than 10 years ago- to explain how to inject a custom XmlSerializer into a WCF Operation in order to customize the response format.

The example code that accompagnies this article can be found in my TimeZoneConverter repository on Github. It hosts a version using .NET 4.8 and a version using .NET 6.0 with the CoreWCF libraries.

Starting from the WSDL File

Our team was tasked to build a new SOAP Service to run as a drop-in replacement for an existing one. We were given a .wsdl file and a folder full of .xsd files to start from.

As we all are C# developers we decided to stick to the available Microsoft tooling. We created a Visual Studio C# project on the .NET Framework 4.8 and generated the ServiceContract using SvcUtil.exe in following Powershell script:

$svcUtilExe = "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\x64\SvcUtil.exe"
& $svcUtilExe "./../../Contracts/TimeZoneConverter.wsdl" "./../../Contracts/TimeZoneConverter.xsd" /t:code /language:"C#" /ser:XmlSerializer /syncOnly /o:ITimeZoneConverter.cs /n:"*,TimeZoneConverter.Contracts" /d:"./Contracts" /config:"service.config"

Custom XmlSerializer

We needed to introduce a custom date/time format but changing the classes generated by svcutil.exe is highly discouraged because the changes will be overwritten when regenerating the files. These classes however are generated as partial classes, so it is possible to inject custom properties and tell the XmlSerializer to use those.

The ToDateTime field in the XSD was defined as a datetime with the toDateTimeField backing field, that can represent either UTC or local time. But this was not sufficient as we needed to be able to respond with any timezone offset. WCF’s default Xml Serializer cannot do this, so we needed to inject a custom one.

This is the TimeZoneConversionResponse as generated by svcutil.exe:

[System.CodeDom.Compiler.GeneratedCodeAttribute("svcutil", "4.8.3928.0")]
[System.SerializableAttribute()]
[System.Diagnostics.DebuggerStepThroughAttribute()]
[System.ComponentModel.DesignerCategoryAttribute("code")]
[System.Xml.Serialization.XmlTypeAttribute(Namespace="http://bartjolling.github.io/")]
public partial class TimeZoneConversionResponse
{
    
    private System.DateTime toDateTimeField;
    
    /// <remarks/>
    [System.Xml.Serialization.XmlElementAttribute(Order=0)]
    public System.DateTime ToDateTime
    {
        get
        {
            return this.toDateTimeField;
        }
        set
        {
            this.toDateTimeField = value;
        }
    }
}

By creating below partial class, we added an additional ToDateTimeOffset string property with the ToDateTimeOffsetField as backing field. It works as follows:

  • When creating the response, use the setter of ToDateTimeOffset to validate and parse the value into its backing field. Also set the original toDateTimeField to the corresponding UTC value.
  • When serializing the response, the Xml Serializer reads the getter of ToDateTimeOffset. The value in the backing field will be formatted with the specified offset.
public partial class TimeZoneConversionResponse
{
    private DateTimeOffset ToDateTimeOffsetField;

    [XmlElement(Order = 1)]
    public string ToDateTimeOffset
    {
        get
        {
            return ToDateTimeOffsetField.ToString("o", CultureInfo.InvariantCulture);
        }
        set
        {
            ToDateTimeOffsetField = DateTimeOffset.ParseExact(value, "o", CultureInfo.InvariantCulture);
            toDateTimeField = ToDateTimeOffsetField.UtcDateTime;
        }
    }
}

We then configured the XmlSerializer to replace the old property with the new one during deserialization:

public static XmlAttributeOverrides SerializerOverrides()
{
    // Ignore ToDateTime during serialization
    var toDateTimeAttributes = new XmlAttributes() { XmlIgnore = true };

    // Use ToDateTimeOffset during serialization
    var toDateTimeOffsetAttributes = new XmlAttributes();
    toDateTimeOffsetAttributes.XmlElements.Add(new XmlElementAttribute()
    {
        ElementName = "ToDateTime",
        Order = 0
    });

    var attributeOverrides = new XmlAttributeOverrides();

    attributeOverrides.Add(typeof(TimeZoneConversionResponse), "ToDateTime", toDateTimeAttributes);
    attributeOverrides.Add(typeof(TimeZoneConversionResponse), "ToDateTimeOffset", toDateTimeOffsetAttributes);

    return attributeOverrides;
}

Note how the OrderAttribute of ToDateTimeOffset was set to 1 (instead of 0) and was only set back to 0 at the same time as replacing the ToDateTime field in SerializerOverrides. This way, the modified TimeZoneConversionResponse could still be read by a vanilla XmlSerializer if desired, albeit without benefitting from the custom field and its formatting.

Creating the XmlObjectSerializer

Unfortunately, it is not possible to pass the XmlAttributesOverrides to the XmlSerializer that is used by the WCF pipeline. However, we can wrap it in a custom XmlObjectSerializer and tell WCF to use that one. Andrew Arnott describes how to do this on Microsoft’s DevBlog.

XmlObjectSerializer requires implementing some methods. Those just forward to methods on the wrapped XmlSerializer instance. The important one here is WriteObject. For a full implementation, check out my TimeZoneConverter repository on Github.

public class ConvertToOffsetResponseSerializer : XmlObjectSerializer
{

    private readonly XmlSerializer _serializer;
    private readonly XmlSerializerNamespaces namespaces;

    public ConvertToOffsetResponseSerializer()
    {
        namespaces = new XmlSerializerNamespaces();
        namespaces.Add("xsi", "http://www.w3.org/2001/XMLSchema-instance");
        namespaces.Add("xsd", "http://www.w3.org/2001/XMLSchema");

        _serializer = new XmlSerializer(typeof(ConvertToOffsetResponse), 
            SerializerOverrides(), null, null, "http://bartjolling.github.io/");
    }

    public static XmlAttributeOverrides SerializerOverrides()
    {
        // ... see code block in the previous paragraph
    }

    public override object ReadObject(XmlDictionaryReader reader, bool verifyObjectName)
    {
        if (_serializer.Deserialize(reader) is not ConvertToOffsetResponse deserializedObject)
        {
            return null;
        }

        return deserializedObject;
    }

    public override void WriteObject(XmlDictionaryWriter writer, object graph)
    {
        if (writer == null)
            throw new ArgumentNullException(nameof(writer));

        _serializer.Serialize(writer, graph, namespaces);
    }
}

Injecting the XmlObjectSerializer in the WCF pipeline

Injecting the custom ConvertToOffsetResponseSerializer in the pipeline of a WCF Operation requires:

  • writing a new DispatchMessageFormatter that defines how requests are deserialized and responses are serialized,
  • creating a new OperationBehavior that overwrites the default, built-in formatter for the XmlSerializerOperationBehavior,
  • attaching this OperationBehavior to the WCF Operation using an attribute.

IDispatchMessageFormatter

WCF provides the IDispatchMessageFormatter interface. This is an extension point that allows us to inject classes that can deserialize request messages and serialize response messages in a WCF service application. It’s at this juncture that we can utilize our custom ConvertToOffsetResponseSerializer class.

On the server side, we only needed to customize the SerializeReply method in order to incorporate the time offset into the reply. The request handling remained unchanged. Therefore, for the implementation of the DeserializeRequest method, we leveraged the existing DispatchMessageFormatter that the class had received through its constructor.

public class ResponseDispatchFormatter : IDispatchMessageFormatter
{
    private readonly IDispatchMessageFormatter _requestFormatter;
    private readonly XmlObjectSerializer _reponseSerializer;

    public ResponseDispatchFormatter(IDispatchMessageFormatter requestFormatter)
    {
        _requestFormatter = requestFormatter ?? throw new ArgumentNullException(nameof(requestFormatter));
        _reponseSerializer = new ConvertToOffsetResponseSerializer();
    }

    public void DeserializeRequest(Message message, object[] parameters)
    {
        // Use the default WCF formatter for the request
        _requestFormatter.DeserializeRequest(message, parameters);
    }

    public Message SerializeReply(MessageVersion messageVersion, object[] parameters, object result)
    {
        // Use the custom serializer to format the response
        Message message = Message.CreateMessage(messageVersion, "ConvertToOffset", result, _reponseSerializer);
        return message;
    }
}

IOperationBehavior

The final step was to attach the ResponseDispatchFormatter to the WCF Operation that requires the custom response format. This can be done by creating an implementation of the IOperationBehavior attribute, as shown below.

On server side, we only needed to customize the DispatchBehavior: we retrieve the inner XmlSerializerOperationBehavior formatter behavior, apply the DispatchBehavior and set the ResponseDispatchFormatter we defined above as its formatter.

We passed the original inner formatter to the new DocumentDispatcher because we still use it for deserializing the requests.

public class ResponseSerializerAttribute : Attribute, IOperationBehavior
{
    private IOperationBehavior XmlSerializerBehavior { get; set; }

    public void AddBindingParameters(OperationDescription operationDescription, 
                                     BindingParameterCollection bindingParameters)
    {
        if (XmlSerializerBehavior != null)
        {
            XmlSerializerBehavior.AddBindingParameters(operationDescription, bindingParameters);
        }
    }

    public void ApplyClientBehavior(OperationDescription operationDescription, 
                                    ClientOperation clientOperation)
    {
        throw new NotImplementedException();
    }

    public void ApplyDispatchBehavior(OperationDescription operationDescription, 
        DispatchOperation dispatchOperation)
    {
        XmlSerializerBehavior = operationDescription.Behaviors.Find<XmlSerializerOperationBehavior>();

        if (XmlSerializerBehavior != null && dispatchOperation.Formatter == null)
        {
            // no formatter has been applied yet - let WCF initialize the formatter with the default behavior
            XmlSerializerBehavior.ApplyDispatchBehavior(operationDescription, dispatchOperation);
        }

        // Override the default formatter but first pass it to the custom formatter so both can be used in dispatch
        dispatchOperation.Formatter = new ResponseDispatchFormatter(dispatchOperation.Formatter);
    }

    public void Validate(OperationDescription operationDescription)
    {
        if (XmlSerializerBehavior != null)
        {
            XmlSerializerBehavior.Validate(operationDescription);
        }
    }
}

Applying the Attribute

We were finally ready to inject the custom XmlSerializer into the WCF Operation, by simply applying our ResponseSerializerAttribute to the operation as follows:

public class TimeZoneConverterService : ITimeZoneConverter
{
    [ResponseSerializer]
    public ConvertToOffsetResponse ConvertToOffset(ConvertToOffsetRequest request)
    {
        var conversionRequest = request.TimeZoneConversionRequest;

        var fromDateTime = new DateTimeOffset(conversionRequest.FromDateTime,
            TimeSpan.FromMinutes((double)conversionRequest.FromOffset));

        var toDateTime = fromDateTime.ToOffset(TimeSpan.FromMinutes((double)conversionRequest.ToOffset));

        var response = new TimeZoneConversionResponse()
        {
            ToDateTimeOffset = toDateTime.ToString("o", CultureInfo.InvariantCulture)
        };

        return new ConvertToOffsetResponse(response);
    }
}

And there you have it: a WCF operation using a customized XmlSerializer for fine-grained control over the generated reply.