In just about every custom I've worked with, eventually the topic of immutability in messages comes up. Messages are immutable in concept, that is, we shouldn't change them (except in the case of document messages). Since messages are generally immutable in concept, why not make them immutable in our applications dealing with the messages themselves?

Immutabilty has a number of benefits, ask Mark Seemann laid out in a discussion question on the NServiceBus discussion forum. Typically, a message/command/event is defined in type-oriented systems as a Data Transfer Object (DTO):

public class CreateOrder: ICommand  
{
    public int OrderId { get; set; }
    public DateTime Date { get; set; }
    public int CustomerId { get; set; }
}

Above is an example of a command message contract defined in NServiceBus. There are a number of problems with mutable message types:

  • Message receivers can modify the deserialized representation, "changing the facts"
  • No invariants specified. What's required? What's valid? What can be null?

The natural reaction to these issues are to add behavior to our message types.

Building Immutability

If we want immutability, and record-type-like behavior, we'll need to do a few things. First is to worry about serialization/deserialization. With DTOs, it's rather easy to guarantee that our wire format can be serialized. As long as we stick to "normal" primitives, public setters, and "normal" collection types, everything works well.

But as anyone who has tried to create fully encapsulated domain models that are also data models, we encapsulation brings pain with dealing with data hydration. In encapsulated domain models, there's usually some level of compromise I undergo to get my domain model good enough for our purposes. It will never be perfect - but perfection is not the goal, shipping is.

To build immutabilty in C#, we can guarantee this with interfaces (full sample from the NServiceBus docs):

public interface ICreateOrder : ICommand  
{
    int OrderId { get; }
    DateTime Date { get; }
    int CustomerId { get; }
}

We need to use an interface because many serializers don't support C#'s read-only properties. If you define your message type as readonly properties:

public class CreateOrder : ICommand  
{
    public CreateOrder(int orderId, DateTime date, int customerId)
    {
        OrderId = orderId;
        Date = date;
        CustomerId = customerId;
    }

    public int OrderId { get; }
    public DateTime Date { get; }
    public int CustomerId { get; }
}

Then behind the scenes, the C# compiler creates a readonly backing field, and the constructor sets this readonly field. Instead, you have to create private setters to allow deserialization to even be possible

public class CreateOrder : ICreateOrder  
{
    public CreateOrder(int orderId, DateTime date, int customerId)
    {
        OrderId = orderId;
        Date = date;
        CustomerId = customerId;
    }

    public int OrderId { get; private set; }
    public DateTime Date { get; private set; }
    public int CustomerId { get; private set; }
}

Then your handler just uses that readonly interface. I also see that my IDE sees that those private setters are redundant for code purposes, but I can't remove them, or my serialization won't work.

We get immutability, but at a price - more code to write, and funny, still not semantically valid code that has to know how exactly our serializers work. Is it even worth it?

What Even Is A Message

In many systems, we substitute "type" for "message" or "contract". But that's not what a message is - our message is what's on the wire. The schema or contract is the agreement or specification for what that message should look like, and expected application-level semantics around processing that message.

In the web API world, quite a lot of work has gone into building specifications around APIs, first with Swagger and now around the Open API specification. In fact, Swagger is now built around Open API Spec (OAS). A wealth of tooling now supports defining, describing, and using Open API-spec-defined APIs.

Not so much on the durable message side of things. Many specifications exist on message protocols, but not the messages themselves. It's really up to producers and consumers to build specifications on the content of the messages.

It's a common attempt in messaging systems to try and define the schema as the type, but the problem is a type system is semantically similar, but not equivalent, to a schema. You wind up having the issues that WSDL had - schemas that had too strict rules or assumptions on specific runtime's type systems. Anyone that's tried to consume a Java web service from .NET will know how off this was.

Ultimately, the type is not the message, but a convenient mechanism of describing the schema and providing serialization. It isn't the message, though, and we should refrain from trying to put object-oriented concepts on top of it. Those don't translate to wire formats, or even message schema specifications.

Schemas and Contracts and Classes (oh my)

The third tenet of Service Oriented Architecture is:

Services share schema and contract, not class (or type)

But what immutability in C# is trying to do is share and enforce classes/types amongst producers/consumers, moving the DTO past just a serialization helper to an actual behavioral object. Building behavior into our serialization type would break a tenet of SOA.

Our producers and consumers should instead focus on the message schema and contract, and application protocols around such, than the manifestation in a specific type system. Ideally, we could define our schemas and contracts, and share those, but we're still a ways off from being able to do so (like you could with WSDL or Open API).

Until then, we can share types, but only if we understand that the type is not the message, the schema defines the message, and the type is only a convenience mechanism to assist in describing and materializing a message.

Today, just use a DTO as your message type, keep it simple, and keep an eye out for efforts to help define (and validate) async messages, and tooling to help define message types/objects based on your target framework/runtime of choice.