In this article series, I talk about the coding practices that I found to be the most beneficial in my experience. In the last two parts, I talked about the following practices:
1. Automated testing
2. Separation of data and behavior
3. Immutability
In this part, I will talk about data modeling and making state and other impurities visible.
Note: In this article, I give you advice based on my 9+ years of experience working with applications. Although I have worked with many kinds of applications, there are probably kinds that I did not work with. Software development is more of an art than a science, so ensure that the advice I give you here makes sense in your case before using it.
Practice #4: Model your data accurately
Make sure that the code units that model data are doing it accurately.
In practice #2, I recommended that you separate data from behavior. If you do that, then there will be units of code in your application whose only job is to model data.
I call these data objects.
In the Designing Data Objects in C# and F# article, I went in depth about how to design data objects. In another article, I gave examples of suboptimal data object designs and suggested improvements. I also wrote another article—called Function parameters in C# and the flattened sum type anti-pattern—where I talked about how easy it is for function inputs to become confusing as functions evolve.
In my opinion, modeling data objects is much more important than modeling behavior. If you are accurate with modeling your data objects, the behavior code that you write will be guided by the restrictions imposed by the data object types.
When I start writing a function, before writing any code in the function itself, I think about the inputs and outputs of the function. Except for the simple cases where built-in data types (e.g. string, int) are enough, I create special data objects to model the function inputs and outputs. I spend enough time on these.
Of course, I do not always create new data types. The reason is that many functions deal with the same data types. Once you write a few functions, you have already created data types that are likely to be used as-is in the functions that you will write next. This is true because functions in the same application are solving a single problem.
Once the data types are defined, my mission of writing the function itself (the behavior) becomes easier. Designing the data objects would have already made me think about the different possible input values that the function can take and the possible output values it will produce. More concretely, this allows me to split the process of writing the function to:
Step 1: think about the inputs and outputs of the function regardless of how the function will convert inputs to outputs.
Step 2: write code to convert the inputs to outputs. This is of course a simplification, but you get the idea.
Of course, such separation is not always done perfectly. It happens sometimes that once you are in Step 2, you realize that there are cases that are not modeled in the input and output data types.
Still, there is a lot of value in such separation.
Not only will well designed input and output data objects help you with writing the behavior code of your functions, they will also make it easier for people (you included) to understand your functions. We developers spend much more time reading functions than we spend writing them.
Practice #5: Make impurities visible
Make sure that the impure parts of your code are visible to readers of your code. Let me explain.
What are pure functions?
I talked about pure (and impure) functions in the following articles:
Writing Honest Methods in C#
Writing Pure Code in C#
Composing Honest Methods in C#
A pure function is a function whose output depends solely on the arguments passed to it. If we invoke a pure function twice using the same input values, we are guaranteed to get the same output. Also, a pure function has no side effects.
All of this means that a pure function cannot mutate a parameter, mutate or read global state, read a file, write to a file, etc. Also, a pure function cannot call another function that is impure.
How to make impurities visible?
By impurities, I mean the things than make an otherwise pure function, impure.
One kind of impurity is state. In practice #3, I talked about making data objects immutable. If you do that, then you minimize the state in your applications.
Still, we sometimes require some state in applications. In this practice, I am advising you to keep that state visible.
For example, instead of having global variables that multiple functions use to read and write state, create state parameters (e.g. ref parameters) and pass them to the functions that need them. This way you have made visible the fact that these functions might read or update state. This makes it easier for developers to understand your code. I talk about this in details in the Global State in C# Applications article.
Another kind of impurity I want to talk about is related to I/O.
Few examples are reading or writing to a file, reading the system timer, writing or reading from a database, etc. If a function does any of these, extract the code that does the I/O into a dependency and make that dependency visible in your code. Let me show you an example. Let’s say you have this code:
public class ReportGenerator : IReportGenerator
{
public Report Generate(int customerId)
{
//..
File.WriteAllText(reportCopyPath, reportText);
//..
}
}
This class generates reports for customers. You give the Generate method a customerId, and it gives you back a Report object. Somewhere in the middle of the method, there is a statement that writes a copy of the report to some folder that holds copies of all generated reports.
What you can do is extract the call to File.WriteAllText into a dependency like this:
public class ReportGenerator : IReportGenerator
{
private readonly IFileAllTextWriter fileAllTextWriter;
public ReportGenerator(IFileAllTextWriter fileAllTextWriter)
{
this.fileAllTextWriter = fileAllTextWriter;
}
public Report Generate(int customerId)
{
//..
fileAllTextWriter.WriteAllText(reportCopyPath, reportText);
//..
}
}
public interface IFileAllTextWriter
{
void WriteAllText(string path, string contents);
}
public class FileAllTextWriter : IFileAllTextWriter
{
public void WriteAllText(string path, string contents)
{
File.WriteAllText(path, contents);
}
}
We now made the fact that the ReportGenerator might write to the file system more visible. The ReportGenerator class is now more honest.
In the Composition Root, when you construct the ReportGenerator class, you explicitly give it an instance of the FileAllTextWriter class like this:
var reportGenerator =
new ReportGenerator(
new FileAllTextWriter());
It is important for the developers who read your Composition Roots to see the impure behavior that your classes use.
In the above example, I used objects and interfaces to model behavior. The same thing can be done in other coding styles. For example, you can do the same thing with static methods and delegates.
In the specific case of the example above, a good idea might be to have a single FileSystem class that contains not just a WriteAllText method, but other file system related methods.
Another thing to note about the Generate method is that this method might have multiple responsibilities; It generates reports for customers, and it also saves copies of these reports to a special folder. This might not be the best way to model the behavior. However, as far as making impurities visible, extracting the WriteAllText method to a special dependency is enough.
Conclusion:
This article series is about the coding practices that I found to be the most beneficial during my work in software development.
In this part, Part 3, I talk about the #4 and #5 most important practices: modeling data accurately and making impurities visible.
Spend enough time on the data objects in your programs.
Not only will well-designed data objects make it easier for the readers of your code (including yourself) to understand the code, they also make it easier for you to write your behavior code (your functions) since they put restrictions on the inputs the functions receive and the outputs they produce.
You make impurities visible by explicitly passing state to functions instead of having functions access global state under the hood. You also make impurities visible by extracting impure behavior (e.g. I/O access) into special dependencies (e.g. classes) and making visible the fact that your code is using these dependencies.
This article was technically reviewed by Damir Arh.
This article has been editorially reviewed by Suprotim Agarwal.
C# and .NET have been around for a very long time, but their constant growth means there’s always more to learn.
We at DotNetCurry are very excited to announce The Absolutely Awesome Book on C# and .NET. This is a 500 pages concise technical eBook available in PDF, ePub (iPad), and Mobi (Kindle).
Organized around concepts, this Book aims to provide a concise, yet solid foundation in C# and .NET, covering C# 6.0, C# 7.0 and .NET Core, with chapters on the latest .NET Core 3.0, .NET Standard and C# 8.0 (final release) too. Use these concepts to deepen your existing knowledge of C# and .NET, to have a solid grasp of the latest in C# and .NET OR to crack your next .NET Interview.
Click here to Explore the Table of Contents or Download Sample Chapters!
Was this article worth reading? Share it with fellow developers too. Thanks!
Yacoub Massad is a software developer and works mainly on Microsoft technologies. Currently, he works at Zeva International where he uses C#, .NET, and other technologies to create eDiscovery solutions. He is interested in learning and writing about software design principles that aim at creating maintainable software. You can view his blog posts at
criticalsoftwareblog.com. He is also the creator of DIVEX(
https://divex.dev), a dependency injection tool that allows you to compose objects and functions in C# in a way that makes your code more maintainable. Recently he started a
YouTube channel about Roslyn, the .NET compiler.