DEV Community

Cover image for Standardizing a Value Object representation in C#
Nelson Ciofi
Nelson Ciofi

Posted on

Standardizing a Value Object representation in C#

I. Introduction

Have you ever struggled with inconsistent storage and retrieval of domain value objects in your code? As a seasoned developer, I know firsthand how frustrating and time-consuming it can be to juggle different representations of the same data.

Recently, I encountered this problem with a specific value object called Competence Month, and I developed a simple and elegant solution to standardize its storage and retrieval. In this post, I'll share with you my step-by-step implementation process using C# code, and highlight the benefits of standardizing data types and ensuring consistency throughout your system. Whether you're a beginner or an experienced developer, this post will provide you with practical tips and best practices for handling data consistency in your own software projects.

II. The Problem

The principle of competence is a fundamental accounting principle that ensures that revenues and expenses are recognized in financial statements in the period in which they are earned or incurred, regardless of when payment is received or made. This is important for accurate financial reporting and to provide a clear picture of a company's financial health.

In the context of software development, Competence is implemented as a domain value object that encapsulates a month and year combination. However, this simple concept can become quite complex when it comes to storing and retrieving this information. Different representations of Competence, such as a DateTime object with day one of the given month, a string in the format "MM/yyyy", or a full datetime as nvarchar “yyyy-MM-dd hh:mm:ss”, can be used interchangeably, leading to confusion, errors, and time-consuming conversion. Additionally, inexperienced or "tourist" developers may introduce their own representations, further complicating the situation.

Look at the code below, as it illustrate a situation with the given issues:

internal class AccountsReceivable
{
    public AccountsReceivable(string competence, decimal value)
    {
        if (string.IsNullOrWhiteSpace(competence))
        {
            throw new ArgumentException($"'{nameof(competence)}' cannot be null or whitespace.", nameof(competence));
        }

        competence = "01" + competence;

        if (!DateTime.TryParse(competence, out DateTime date))
        {
            throw new ArgumentException("Invalid format.", nameof(competence));
        }

        Competence = date;
        Value = value;
    }

    public decimal Value { get; set; }
    public DateTime Competence { get; }
}

internal class PaymentOrder
{
    public PaymentOrder(string competence, DateTime dueDate, AccountsReceivable[] accountsReceivables)
    {
        if (string.IsNullOrWhiteSpace(competence)) throw new ArgumentNullException(nameof(competence));
        if (dueDate < DateTime.Today) throw new ArgumentOutOfRangeException(nameof(dueDate));
        if (!accountsReceivables.Any()) throw new ArgumentException("Payment order must have at least one account receivable.", nameof(accountsReceivables));

        Competence = competence;
        AccountsReceivables = accountsReceivables;
        DueDate = dueDate;
        Value = accountsReceivables.Sum(a => a.Value);
    }

    public DateTime DueDate { get; }
    public decimal Value { get; }
    public string Competence { get; }

    public bool Paid { get; set; }

    public AccountsReceivable[] AccountsReceivables { get; }
}


internal class PaymentServiceWithIssues
{
    public void PayOrder()
    {
        var service1AccountReceivable = new AccountsReceivable("04/2023", 10);
        var service2AccountReceivable = new AccountsReceivable("04/2023", 15);

        var servicesPaymentOrder = new PaymentOrder("04/2023",
                                                    new DateTime(2023, 04, 15),
                                                    new AccountsReceivable[] {
                                                        service1AccountReceivable,
                                                        service2AccountReceivable
                                                    });

        var paymentCompetence = DateTime.Parse(servicesPaymentOrder.Competence);

        if (service1AccountReceivable.Competence.Month != paymentCompetence.Month ||
           service2AccountReceivable.Competence.Month != paymentCompetence.Month)
        {
            throw new InvalidOperationException("Payment can only happen in the same competence month of accounts receivable.");
        }

        //Some payment logic here.

        servicesPaymentOrder.Paid = true;

        Console.WriteLine("The order was paid successfully!");
    }
}


Enter fullscreen mode Exit fullscreen mode

III. Solution Overview

Our solution involves creating a new struct called CompetenceMonth, which handles the different scenarios in which the month competence information can appear. The struct will include several properties and methods that allow us to convert the competence to different formats, perform arithmetic operations with competences, and compare competences with each other in a very simplistic way.

Here is the code for the CompetenceMonth struct:


public readonly struct CompetenceMonth : IEquatable<CompetenceMonth>
{
    private readonly int year;
    private readonly int month;

    public CompetenceMonth(int month, int year)
    {
        if (year <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(year));
        }

        if (month <= 0 || month > 12)
        {
            throw new ArgumentOutOfRangeException(nameof(month));
        }

        this.year = year;
        this.month = month;
    }

    public CompetenceMonth(DateTime dt)
    {
        year = dt.Year;
        month = dt.Month;
    }

    public CompetenceMonth(string txt)
    {
        if (string.IsNullOrWhiteSpace(txt))
        {
            throw new ArgumentNullException(nameof(txt));
        }

        var txtDt = "01" + txt;

        if (!DateTime.TryParse(txtDt, out var dt))
        {
            throw new ArgumentException("Invalid format", nameof(txt));
        }

        this.year = dt.Year;
        this.month = dt.Month;
    }

    public int Year => year;
    public int Month => month;

    public string ToCompetenceText() => $"{month:D2}/{year}";

    public override bool Equals(object? obj) 
        => obj is CompetenceMonth month && Equals(month);

    public bool Equals(CompetenceMonth other) 
        => year == other.year && month == other.month;

    public override int GetHashCode() 
        => HashCode.Combine(year, month);

    public override string ToString() 
        => ToCompetenceText();


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

    public static bool operator !=(CompetenceMonth left, 
                                   CompetenceMonth right)
        => !(left == right);
}
Enter fullscreen mode Exit fullscreen mode

While the CompetenceMonth struct provides a solid foundation for representing and manipulating month competences, it is currently lacking some important functionality. For example, if we want to use the CompetenceMonth struct with data that is stored in a database, we will need to be able to convert it to and from various formats such as nvarchar and datetime.

At the moment, the CompetenceMonth struct does not provide any methods for such conversions, which means that we would need to write custom conversion logic every time we want to work with month competences in a different format. This is not ideal, as it can be time-consuming and error-prone.

To address this issue, we will need to add some additional functionality to it.

public readonly struct CompetenceMonth : IEquatable<CompetenceMonth>
{
    private readonly int year;
    private readonly int month;

    public CompetenceMonth(int month, int year)
    {
        if (year <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(year));
        }

        if (month <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(month));
        }

        this.year = year;
        this.month = month;
    }

    public CompetenceMonth(int monthCount)
    {
        if (monthCount <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(monthCount));
        }

        CalculateCompetenceFromMonthCount(monthCount, out int month, out int year);

        this.year = year;
        this.month = month;
    }

    public CompetenceMonth(string txt)
    {
        if (string.IsNullOrWhiteSpace(txt))
        {
            throw new ArgumentNullException(nameof(txt));
        }

        ConvertCompetenceFromText(txt, out int month, out int year);

        this.year = year;
        this.month = month;
    }

    public CompetenceMonth(DateTime dt)
    {
        year = dt.Year;
        month = dt.Month;
    }

    public int Year => year;
    public int Month => month;


    public int ToMonthCount() => (year * 12) + month;
    public DateTime ToDateTime() => new(year, month, 1);
    public string ToCompetenceText() => $"{month:D2}/{year}";

    //IEquatable and other equality comparers here.

    private static void CalculateCompetenceFromMonthCount(int monthCount, out int month, out int year)
    {
        year = monthCount / 12;
        month = monthCount % 12;

        while (month < 1)
        {
            month += 12;
            year--;
        }

        while (month > 12)
        {
            month -= 12;
            year++;
        }
    }

    private static readonly char[] validChars = new char[] { '/', '-' };

    private static void ConvertCompetenceFromText(string txt, out int month, out int year)
    {
        var index = txt.IndexOfAny(validChars);

        if (index < 0)
        {
            throw new ArgumentException("Invalid format.");
        }

        var txtSpn = txt.AsSpan();

        var p1 = txtSpn[..index];
        var p2 = txtSpn[(index + 1)..];

        if (p1.Length == 2)
        {
            if (!int.TryParse(p1, out month))
            {
                throw new InvalidOperationException();
            }

            if (!int.TryParse(p2, out year))
            {
                throw new InvalidOperationException();
            }
        }
        else if (p1.Length == 4)
        {
            if (!int.TryParse(p2, out month))
            {
                throw new InvalidOperationException();
            }

            if (!int.TryParse(p1, out year))
            {
                throw new InvalidOperationException();
            }
        }
        else
        {
            throw new ArgumentException("Invalid format.");
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

The implementation of the conversion methods and constructors allow for the conversion of a CompetenceMonth instance to other formats such as month count, DateTime, and text. These methods are very useful in scenarios where the CompetenceMonth instance needs to be stored or displayed in a different format.

However, the current solution may still lack practical conversions to work with other objects. To overcome this limitation, we can take advantage of a C# feature called implicit operator overrides.

Implicit operator overrides allow us to define implicit conversions between types, meaning that we can convert an instance of one type to another type without explicitly calling a conversion method. By defining these implicit operator overrides, we can simplify the conversion process and make it more intuitive. For example, instead of calling a conversion method like ToDateTime() to convert a CompetenceMonth instance to a DateTime, we can simply assign the CompetenceMonth instance to a DateTime variable, and the implicit operator override will handle the conversion automatically.

In summary, implicit operator overrides are a powerful feature in C# that allow for intuitive and easy conversion between types. By leveraging this feature in the CompetenceMonth struct, we can add more practical conversions to database formats such as nvarchar and datetime, making the struct even more versatile and useful.

public readonly struct CompetenceMonth : IEquatable<CompetenceMonth>
{
    private readonly int year;
    private readonly int month;

    //Constructors here

    //Conversion methods here

    //IEquatable and other equality comparers here.

    public static implicit operator int(CompetenceMonth competenceMonth)
       => competenceMonth.ToMonthCount();

    public static implicit operator CompetenceMonth(int monthCount)
        => new(monthCount);

    public static implicit operator DateTime(CompetenceMonth competenceMonth)
        => competenceMonth.ToDateTime();

    public static implicit operator CompetenceMonth(DateTime dateTime)
        => new(dateTime);

    public static implicit operator string(CompetenceMonth competenceMonth)
        => competenceMonth.ToCompetenceText();

    public static implicit operator CompetenceMonth(string txt)
        => new(txt);

}
Enter fullscreen mode Exit fullscreen mode

IV. Data Storage and Retrieval

When working with databases, we may encounter tables with columns that have different formats than the C# types we are using in our code. Two of the most commonly used C# solutions for database handling are EntityFramework and Dapper.

When using Dapper to read data from the database, it automatically converts the database types to the corresponding C# types. With the help of implicit operators, we can then easily convert those types to CompetenceMonth instances. However, we may encounter scenarios where we need to handle the data in a more specialized way. In such cases, we can create specialized mappers using Dapper's TypeHandler class.

For example, the following code creates a TypeHandler that maps CompetenceMonth instances to the month count database format:

public class CompetenceMonthTypeHandlerAsMonthCount : SqlMapper.TypeHandler<CompetenceMonth>
{
    public override CompetenceMonth Parse(object value)
    {
        if (value is int v)
        {
            return new CompetenceMonth(v);
        }

        return new CompetenceMonth();
    }

    public override void SetValue(IDbDataParameter parameter, CompetenceMonth value)
    {
        parameter.Value = value.ToMonthCount();
    }  
}
Enter fullscreen mode Exit fullscreen mode

When using EntityFramework Core, we can map the CompetenceMonth property directly using the Fluent API, as shown in the following example:

public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; }
    public CompetenceMonth Competence { get; set; }
}

public class EmployeeContext : DbContext
{
    public DbSet<Employee> Employees { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Employee>()
            .Property(e => e.Competence)
            .HasConversion(
                v => v.ToMonthCount(),
                v => new CompetenceMonth(v));
    }
}
Enter fullscreen mode Exit fullscreen mode

Alternatively, we can create a generic value converter that can be used for any entity with a CompetenceMonth property:

public class CompetenceMonthValueConverterAsMonthCount : ValueConverter<CompetenceMonth, int>
{
    public CompetenceMonthValueConverterAsMonthCount() : base(
        v => v.ToMonthCount(),
        v => new CompetenceMonth(v))
    { }
}
Enter fullscreen mode Exit fullscreen mode

We can then add this converter at the DbContext level by calling the following extension method:

  public static ModelConfigurationBuilder AddDefaultMonthCompetenceConverter(this ModelConfigurationBuilder configurationBuilder)
    {
        configurationBuilder.Properties<CompetenceMonth>().HaveConversion<CompetenceMonthValueConverterAsMonthCount>();
        return configurationBuilder;
    }
Enter fullscreen mode Exit fullscreen mode

One important aspect to consider in the implementation of the CompetenceMonth struct is the use of the month count representation as an integer. This decision provides a very practical way to store and retrieve the information from databases, as most database engines can efficiently handle integer data types.

Storing CompetenceMonth as an integer can also provide an easy way to select ranges of data. For instance, it is possible to retrieve all records from a database within a given range of CompetenceMonth values by using a simple SQL query with the BETWEEN operator.

On the other hand, if CompetenceMonth was stored as a string, selecting a range of data would require the use of the IN clause, which can be less performatic. Similarly, storing CompetenceMonth as a DateTime object would require additional handling of the days, which can be error-prone.

Therefore, the decision to represent CompetenceMonth as an integer provides a practical and efficient way to store and retrieve the data from databases, which can be very useful in real-world scenarios where the amount of data can be very large.

The extension methods below provide an easy way to find all that ranges whenever they are needed.

public static IEnumerable<string> CompetenceTextRangeTo(this CompetenceMonth cmFrom, CompetenceMonth cmTo)
    {
        if (cmFrom > cmTo)
        {
            (cmTo, cmFrom) = (cmFrom, cmTo);
        }

        while (cmFrom <= cmTo)
        {
            yield return cmFrom.ToCompetenceText();
            cmFrom = cmFrom.AddMonths(1);
        }
    }    

    public static (DateTime, DateTime) DateTimeRangeTo(this CompetenceMonth cmFrom, CompetenceMonth cmTo)
    {
        if (cmFrom > cmTo)
        {
            (cmTo, cmFrom) = (cmFrom, cmTo);
        }

        DateTime fromDate = cmFrom;
        DateTime toDate = cmTo;

        toDate = toDate.AddMonths(1).AddDays(-1);

        return (fromDate, toDate);
    }

    public static (int, int) MonthCountRangeTo(this CompetenceMonth cmFrom, CompetenceMonth cmTo)
    {
        if (cmFrom > cmTo)
        {
            (cmTo, cmFrom) = (cmFrom, cmTo);
        }

        int fromDate = cmFrom;
        int toDate = cmTo;        

        return (fromDate, toDate);
    }
Enter fullscreen mode Exit fullscreen mode

V. Conclusion

In this post, we discussed the problem of inconsistent data storage and retrieval in software development, and how it can be addressed by creating and standardizing value objects. Specifically, we introduced the CompetenceMonth struct, which provides a standardized way of representing and converting month competences in C# code.

We went through the step-by-step implementation of the CompetenceMonth struct, which included defining the struct, implementing constructors and argument validation, defining properties and methods for easy access and conversion of month competences, implementing equality comparisons and hashing, and implementing operators for easy conversion between CompetenceMonth and int/DateTime types. We also discussed the importance of implementing the IEquatable and IComparable interfaces, as well as implicit operator overrides, extension methods, database converters and mappers for EF Core and Dapper.

We also emphasized that these techniques can be applied to any DDD value object, not just CompetenceMonth. By creating and standardizing value objects, we can improve code readability, ensure data consistency, and make it easier to implement algorithms and collections that work with domain concepts.

In conclusion, we encourage readers to apply the discussed techniques to their own DDD value objects, as it can help to prevent common issues with data consistency and provide a standardized way of representing domain concepts in code.

VI. GitHub Repository

I have created a GitHub repository with the code examples discussed in this post, as well as additional implementations, unit tests, examples of how to use the CompetenceMonth struct with Entity Framework Core and Dapper, and of course, a solution for the presented problem. We encourage readers to check out the repository and experiment with the code to gain a deeper understanding of how to implement and use value objects in their own projects. The repository can be found at github.com/nelsonciofi/Competence.

I would love to hear your thoughts on this post and how I handled this value object inconsistency. Have you encountered similar challenges in your projects? How did you handle them? Feel free to leave a comment below and join the discussion.

P.S.: This post was written with the help of AI, but the implementations are all mine.
P.S.2: The cover image was also created with AI.
P.S.3: Do we still need a blog after the invention of GPT?

Top comments (0)