DEV Community

Eric Damtoft for DealerOn Dev

Posted on

Upgrade your Value Objects in 10 Steps

In object-oriented programming, it's tempting to overuse built-in types. For example, if you think of an object representing a vehicle, that vehicle's model year may be represented as a 32-bit integer, and it's make as a string. For many purposes, this is OK, but defining strong types for those values within your domain can make your code more expressive and allow the compiler become a powerful ally in verifying that your code is being used the way you intended it to.

However, if you look at built-in types, there are a lot of hidden features they implement which can add value. A simple implementation of a domain-specific value-object will likely miss some of these features and eventually lead to frustration for the users of that code. We'll look at some ways to make a true, fully featured value object with all the bells and whistles you've come to expect from .NET types.

As an example, let's use a simple Coordinate with an X and Y value.

class Coordinate
{
    public int X { get; set; }
    public int Y { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Upgrade 1: Immutability

Not all types should be immutable, but any type which fundamentally represents a value can benefit from immutability. Immutability will generally simplify logic and clarify how your type is supposed to be used.

public class Coordinate
{
    public Coordinate(int x, int y) => (X,Y) = (x,y);

    public int X { get; }
    public int Y { get; }
}
Enter fullscreen mode Exit fullscreen mode

Notice the constructor? This is just for style. Feel free to use standard assignments, but tuple assignments make for a simple one-liner. If you have more than 2 or 3 values to assign, it's probably better to use classic assignments.

Upgrade 2: Make it a struct

Structs have a number of advantages over classes for types which fundamentally represent a single value. They're passed by value and usually only allocated on the stack instead of a reference to the heap so in many cases they'll avoid allocations and reduce garbage collection, thus improving performance. Structs are best avoided for mutable types because of how they're passed, but for a small immutable type like a coordinate point, a struct is the way to go. See more about when to use a struct vs a class here. You can also use the readonly keyword so the compiler will enforce that the type is immutable.

public readonly struct Coordinate
Enter fullscreen mode Exit fullscreen mode

Upgrade 3: Equatability

A good value type should be equatable by it's values. I.E. a coordinate (1,2) should equal another coordinate (1,2). Along with the Equals and GetHashCode methods, we should make sure that you can use the == and != operators and implements IEquatable<Coordinate>.

public override bool Equals(object obj) => obj is Coordinate coordinate && Equals(coordinate);

public bool Equals(Coordinate other) => (X, Y) == (other.X, other.Y);

public override int GetHashCode() => HashCode.Combine(X, Y);

public static bool operator ==(Coordinate left, Coordinate right) => left.Equals(right);
public static bool operator !=(Coordinate left, Coordinate right) => !(left == right);
Enter fullscreen mode Exit fullscreen mode

Notice some interesting syntax in the Equals method. Comparing 2 tuples is an easy way to compare a number of properties. This is again a style choice and feel free to stick with a classic equality check for each property.

Upgrade 4: Formattability

We'It's common to need to specify a way of formatting a value to a string. It's always good practice to add a ToString() method, but there are often a variety of ways to represent any value as a string. In our case, we'll consider the numbers which make up the coordinate and allow them to be formatted like any other number value. To make this work, we'll add the IFormattable interface to our class. You could probably imagine a more sophisticated implementation that allowed for custom delimiter, etc. but for simplicity, we'll just forward the format onto each value.

public string ToString(string format, IFormatProvider formatProvider)
{
    return X.ToString(format, formatProvider) + "," + Y.ToString(format, formatProvider);
}
Enter fullscreen mode Exit fullscreen mode

This way, you can use it in interpolated strings such as $"coordinates: {c:00}" to result in "01,02";

Upgrade 5: Parsability

If you notice, most built-in types have a static Parse and TryParse method. These are useful, and implementing them following the established pattern will make it easy for users.

We'll skip the details of actually parsing a value for brevity

public static Coordinate Parse(string s) => CoordinateParser.Parse(s);

public static bool TryParse(string s, out Coordinate c) => CoordinateParser.TryParse(s, out c);
Enter fullscreen mode Exit fullscreen mode

Upgrade 6: Convertibility

Being able to easily convert between types is crucial to making a user-friendly type.

We can add implicit and explicit conversions to and from other types. This will allow the c# compiler to automatically translate these types either with or without an explicit cast.

For our coordinate class, let's imagine we also had an Vector type with a magnitude and a direction.

public static explicit operator Vector(Coordinate c) => Vector.FromCoordinate(c);
Enter fullscreen mode Exit fullscreen mode

Upgrade 7: Operators

We've already added equality operators, and conversions are also considered by c# to be operators, but depending on the structure, consider adding additional operators specific to your case. In our instance, we'll just add some basic math operations, but you can also consider use cases like DateTime - DateTime = TimeSpan where the types are representative of the actual meaning of the object.

public static Coordinate operator +(Coordinate c) => c; // doesn't do anything, but complements minus
public static Coordinate operator -(Coordinate c) => new Coordinate(-c.X, -c.Y);
public static Coordinate operator ++(Coordinate c) => new Coordinate(c.X+1, c.Y+1);
public static Coordinate operator --(Coordinate c) => new Coordinate(c.X-1, c.Y-1);
public static Coordinate operator +(Coordinate left, Coordinate right) => new Coordinate(left.X + right.Y, left.X + right.Y);
public static Coordinate operator -(Coordinate left, Coordinate right) => new Coordinate(left.X - right.X, left.Y - right.Y);
public static Coordinate operator *(Coordinate left, Coordinate right) => new Coordinate(left.X * right.Y, left.X * right.Y);
public static Coordinate operator /(Coordinate left, Coordinate right) => new Coordinate(left.X / right.Y, left.X / right.Y);
public static Coordinate operator %(Coordinate left, Coordinate right) => new Coordinate(left.X % right.Y, left.X % right.Y);
Enter fullscreen mode Exit fullscreen mode

Upgrade 8: Deconstruction

If we want to easily deconstruct values out of our type, we can add a Deconstruct method.

public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
Enter fullscreen mode Exit fullscreen mode

This allows us to pull values out using the same syntax as tuple deconstruction.

var (x, y) = new Coordinate(1,2);
Enter fullscreen mode Exit fullscreen mode

Upgrade 9: Debuggability

This one is a bit niche, but can be quite helpful for providing a view of a value geared specifically towards developers. If you inspect a value in the debugger, it'll show you the value from ToString(), but you can customize this by adding a DebuggerDisplayAttribute. In this case, we'll keep it mostly the same as the ToString(), but you could add other useful debugger information or context here as well.

[DebuggerDisplay("({X},{Y})")]
public readonly struct Coordinate : IEquatable<Coordinate>, IFormattable
Enter fullscreen mode Exit fullscreen mode

Upgrade 10: Documentation

This one may go without saying, but once you're done adding features, make sure to take the time to put good XML comments on everything public. Styles and coding guidelines vary, but in general, I tend to go with the principal of only commenting when necessary and to add clarity, but if you're writing a library to be used by others, it's best to take the time to comment everything.

Also, take advantage of more than just the <summary> tag. There are a number of other useful tags for XML comments which are especially valuable if you use tools like DocFX to automatically generate a documentation website with details and examples.

/// <summary>
/// Converts a string into a coordinate object
/// </summary>
/// <param name="s">A string containing a coordinate to convert</param>
/// <returns>A coordinate equivelant to the string contained in <paramref name="s"/></returns>
/// <exception cref="FormatException">A format exception will be thrown if the structure of the string is unexpected</exception>
/// <example>
/// This demonstrates basic usage of the Parse method:
/// <code>
/// var c = Coordinate.Parse("1,2");
/// </code>
/// </example>
public Coordinate Parse(string s)
Enter fullscreen mode Exit fullscreen mode

Putting it all together

We started with a simple class:

public class Coordinate
{
    public int X { get; set; }
    public int Y { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

This might be fine for a simple serialization bucket, but once our upgrades are all done, we've got a class worthy of inclusion in a world-class .NET library. Here's the end result (with comments omitted for brevity):

[DebuggerDisplay("({X},{Y})")]
public readonly struct Coordinate : IEquatable<Coordinate>, IFormattable
{
  public Coordinate(int x, int y) => (X, Y) = (x, y);

  public int X { get; }
  public int Y { get; }

  public override bool Equals(object obj) => obj is Coordinate coordinate && Equals(coordinate);

  public bool Equals(Coordinate other) => (X, Y) == (other.X, other.Y);

  public override int GetHashCode() => HashCode.Combine(X, Y);

  public override string ToString() => ToString(null, null);

  public string ToString(string format, IFormatProvider formatProvider)
  {
    return X.ToString(format, formatProvider) + "," + Y.ToString(format, formatProvider);
  }

  public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);

  public static Coordinate Parse(string s) => CoordinateParser.Parse(s);

  public static bool TryParse(string s, out Coordinate c) => CoordinateParser.TryParse(s, out c);

  public static explicit operator Vector(Coordinate c) => Vector.FromCoordinate(c);

  public static bool operator ==(Coordinate left, Coordinate right) => left.Equals(right);
  public static bool operator !=(Coordinate left, Coordinate right) => !(left == right);

  public static Coordinate operator +(Coordinate c) => c; // doesn't do anything, but complements minus
  public static Coordinate operator -(Coordinate c) => new Coordinate(-c.X, -c.Y);
  public static Coordinate operator ++(Coordinate c) => new Coordinate(c.X+1, c.Y+1);
  public static Coordinate operator --(Coordinate c) => new Coordinate(c.X-1, c.Y-1);
  public static Coordinate operator +(Coordinate left, Coordinate right) => new Coordinate(left.X + right.Y, left.X + right.Y);
  public static Coordinate operator -(Coordinate left, Coordinate right) => new Coordinate(left.X - right.X, left.Y - right.Y);
  public static Coordinate operator *(Coordinate left, Coordinate right) => new Coordinate(left.X * right.Y, left.X * right.Y);
  public static Coordinate operator /(Coordinate left, Coordinate right) => new Coordinate(left.X / right.Y, left.X / right.Y);
  public static Coordinate operator %(Coordinate left, Coordinate right) => new Coordinate(left.X % right.Y, left.X % right.Y);

}
Enter fullscreen mode Exit fullscreen mode

Ultimately, how far down the road you want to go is a judgement call. For some cases, the first version of the type is all that's required and exactly what's needed. But, if you're writing a library or core domain code, it's probably worth breaking out all the stops to make the experience smoother and more predictable for your users.

Discussion (1)

Collapse
jayjeckel profile image
Jay Jeckel

A well made struct is a thing of beauty. Great breakdown of an underappreciated feature!