What Makes a Good Class?
Classes are used for many purposes, so properties of a good class in one context will be different from properties of a good class in another context.
We use classes for:
- data containers (dtos)
- function containers (modules)
- types
- combination of data and behavior (objects)
In all cases, what makes a class good will be a function of the purpose of the class, so it’s good to think about why the class exists.
The main reason we use any structure is to reduce complexity. Our tiny human brains can only handle 5-9 things in memory at a time, so in order to handle anything more complex (like software) we need to make building blocks that we can understand, and then combine them together.
In order for a class to be used as a building block, we need to think about what it promises us. We need to think about what it takes off our plate so we don’t need to think about them anymore.
Dtos
class UserInfo {
public string Name { get; set; }
public string Email { get; set; }
}
This pattern is common when integrating with something. For example, this could be mapped from a form submission by MVC, or it could be mapped from an xml value coming from some service, etc.
It’s useful to deal with C# instead of http strings or xml, but there’s not a lot more benefit to a class like this. We can’t understand much about anything from this class, we need to see how it’s used to understand anything.
How many possible states does this have?
var ui = new UserInfo { Name = "Bobby", Email = "bobby@email.com" }
ui = new UserInfo { Email = "bobby@email.com" };
ui = new UserInfo { Name = "Bobby" };
Basically infinite, so using classes like this for logic will probably end in tears.
What does this promise us? Basically nothing, so it’s not much use as a building block. Dtos are good at the edges of a system.
Static classes
Pretty self explanatory. They’re containers for functions, and they’re great as long as they don’t use globals. Basically everything from the functions section applies.
Static classes that get/set shared values from somewhere (eg HttpContext in an Asp.Net application) are hard to deal with because they don’t help manage the complexity.
Classes for business rules or logic
class Point {
public readonly int X;
public readonly int Y;
public Point(int x, int y) {
this.X = x;
this.Y = y;
}
}
This is able to express some “truth”, and thus has value. We can know by looking at this that there will always be both an X and a Y.
More complex invariants
This type of class is capable of expressing more complex rules as well, eg: a point in a 20x20 grid.
class Point {
public readonly int X;
public readonly int Y;
public Point(int x, int y) {
if (x > 20 || x < 1) throw new ArgumentException(nameof(x));
if (y > 20 || y < 1) throw new ArgumentException(nameof(y));
this.X = x;
this.Y = y;
}
}
Now we can know by looking here that all values of this type will have both X, and Y, and they will both be between 1 and 20. That’s called an Invariant – something that’s always true about a class, and it’s useful. If a class doesn’t express one or more invariants, it’s worth thinking about why it exists.
This is nice because by understanding just one place, without needing context, we can know something about our program. It’s also easy to test in cases where that’s valuable.
Even more complex invariants
abstract class CreditCardPayment {
public sealed class Token : CreditCardPayment {
public readonly long Value;
public Token(long value) {
Value = value;
}
}
public sealed class RsaEncryptedPan : CreditCardPayment {
public readonly string Value;
public RsaEncryptedPan(string value) {
Value = value;
}
}
}
void ProcessCc(CreditCardPayment payment) {
//...
}
ProcessCc(new CreditCardPayment.Token(12344));
ProcessCc(new RsaEncryptedPan("encCc"));
Group of functions operating on the same data, configuration, or environment
We need to calculate the last four digits and type of a credit card. Both these functions are simpler if we can assume certain things about the input, like that it is in a normalized form.
class CreditCardStuff {
public string CalculateType() {
if (_cc[0] == '4') {
return "visa";
}
throw new InvalidOperationException("only visas allowed!");
}
public string LastFour() {
return _cc.Substring(_cc.Length - 4, 4);
}
private readonly string _cc;
public CreditCardStuff(string rawCc) {
_cc = rawCc.Replace("-", "");
//one place to see/protect assumptions and invariants about _cc
}
}
The main benefit is each member method can take advantage of the code in the ctor, and is relatively easy to follow because everything depends on the ctor (and in the example nothing else).
This is basically fine on a small scale. The important criteria is: what sort of value does this provides, and how consistent/reliable is this value? This can normaly be “measured” by how much a client needs to know about the implementation. This is the type of situation where it’s often fine at first, but grows more complex over time. If that starts happening, it’s better to pull the assumptions and invariants we’re assuming in these functions into a separate type, eg, a NormalizedCc type.
Common pitfalls are introducing coupling between methods, like:
class CreditCardStuff {
public string CalculateType() {
_cc = _cc.Replace("-", "");
if (_cc[0] == '4') {
return "visa";
}
throw new InvalidOperationException("only visas allowed!");
}
public string LastFour() {
return _cc.Substring(_cc.Length - 4, 4);
}
private string _cc;
public CreditCardStuff(string rawCc) {
_cc = rawCc;
}
}
This works fine so long as CalculateType is always called first, and is the sort of thing that happens accidentally. This gets confusing to the user of this class. Similarly, what happens if there’s an empty ctor? In both these cases clients need to know about these peculiarities of implementation in order to effectively use this class, which really limits the usefulness. For a class of this sort to be useful it must have and protect invariants. Otherwise it’s just a bucket of code providing little (sometimes negative) value to clients.
Making the members immutable would be helpful again, and has no downsides.
Smart Constructors
All instances of classes are created with constructors, and all constructors return an instance, there are several instances where using a factory method instead of a constructor makes sense.
class CreditCardStuff {
public CreditCardStuff(string rawCc) {
var cc = rawCc.Replace("-", "");
if (cc[0] == '4') {
this.CcType = "visa";
}
throw new InvalidOperationException("only visas allowed!");
}
public readonly string CcType;
}
Throwing exceptions like this preserves the invariants of the class, ie, I can know without looking at clients that only valid values exist for CcType, and that has a lot of value. However, that puts the need to check/verify inputs on the client, and that may not be desired. A factory method that can return a failure to a client can be nice. This is sometimes called the smart constructor pattern.
class CreditCardStuff {
private CreditCardStuff(string aType) {
this.CcType = aType;
}
public readonly string CcType;
public static Maybe<CreditCardStuff> Create(string rawCc) {
if (_cc[0] == "4") {
return new CreditCardStuff("visa");
}
return Maybe.Empty;
}
}
This still preserves the invariant, but now the client isn’t dealing with exceptions or testing/checking the same values as the class.
Conclusion
What makes a class good depends on the purpose of the class. If a class is more than a dumb data bucket, in order for it to be useful as a building block, it needs to reduce complexity. The way it does that is by being able to make promises and guarantees without needing to understand all the clients.
Next up: Designing with types