C# / Basic: Typed Programs and Object Contracts / Lesson 5
Classes, Interfaces, and Invariants
Model behavior through constructors, properties, and small interfaces without confusing inheritance with ownership or immutability.
On this page
Lesson goals
- Establish valid object state at construction.
- Distinguish instance, static, virtual, and interface behavior.
- Explain class references and shallow record copies.
A class defines valid state and operations
A class groups data and behavior around an invariant. Constructors establish required state, properties expose controlled access, and methods perform coherent transitions. Private fields prevent ordinary callers from bypassing those transitions. Encapsulation matters when fields are related, not merely because private syntax is available.
For a nonnegative counter, every permitted operation must keep the value representable and nonnegative. A property with a public unrestricted setter could undermine that rule. Validate before mutation and document failure so a rejected operation leaves a valid object. A constructor that throws must not publish a partially initialized instance.
Keep a transition within its domain
The first example uses a constructor guard and a Try-style increment that refuses the maximum value before adding. The fixed initial value is eight, so increment succeeds and Value becomes nine. The caller cannot set Value directly. This is a small contract whose valid states can be reviewed without inspecting an entire application.
A static member belongs to the type rather than one object instance. Static mutable state introduces shared dependencies and often complicates tests. Prefer instance state when ownership is naturally per object. Access modifiers are compile-time boundaries within their rules, not a security boundary against hostile code running with equivalent process access.
using System;
var counter = new Counter(8);
Console.WriteLine($"advanced={counter.TryAdvance()} value={counter.Value}");
internal sealed class Counter
{
public int Value { get; private set; }
public Counter(int value)
{
if (value < 0) throw new ArgumentOutOfRangeException(nameof(value));
Value = value;
}
public bool TryAdvance()
{
if (Value == int.MaxValue) return false;
Value++;
return true;
}
}
advanced=True value=9
The constructor establishes the invariant and TryAdvance preserves it before exposing nine.
An interface names the consumer’s needs
An interface defines a behavior contract that different types can implement. Prefer a small consumer-focused interface over a giant list of implementation conveniences. A variable typed as an interface can refer to a concrete implementation, but the semantic promises—error behavior, mutation, blocking, and concurrency—still need documentation. Matching method signatures is only the structural part.
Inheritance models an is-a relationship with substitutable behavior. Composition often keeps dependencies clearer when one object simply uses another. Use virtual and override when dynamic dispatch is intended, and avoid accidentally hiding a base member with new when callers expect an override. Sealed can close an extension point deliberately.
Inject behavior rather than hard-code it
The second example depends only on ILabel.Name, not the concrete FixedLabel class. The interface is enough to render a greeting. A test can supply another small implementation without a web server, database, or dependency-injection framework. Constructor parameters make the concrete dependency visible.
Value types can implement interfaces too, which can introduce boxing depending on usage. Do not choose a class or struct solely from a slogan that one is faster. Consider identity, mutability, copying, and actual measurements. Later lessons examine boxing and value semantics in more depth.
using System;
ILabel label = new FixedLabel("Ada");
Console.WriteLine($"hello {label.Name}");
internal interface ILabel
{
string Name { get; }
}
internal sealed class FixedLabel : ILabel
{
public string Name { get; }
public FixedLabel(string name) => Name = name ?? throw new ArgumentNullException(nameof(name));
}
hello Ada
The caller observes the interface property and receives Ada from the concrete instance.
Equality and copying are explicit design choices
Classes normally have reference identity unless equality is deliberately implemented; records provide generated value-oriented equality behavior. A record class is still a reference type. A with expression produces a shallow copy, so a list or other mutable reference member can remain shared. init restricts assignment through that property after initialization but does not recursively freeze its target.
Choose equality from the domain and keep hash-code behavior consistent for dictionary keys. Mutating fields that participate in equality after insertion can make keyed lookup unreliable. Prefer immutable keys and small cohesive models. Document whether a returned model is a live view, a shallow snapshot, or independent data.
Common errors and fixes
An object can be placed in an invalid state by a setter.
- Why it happens
- Its invariant is not protected by operations.
- How to fix it
- Validate mutations and restrict direct writes.
- Prevent it next time
- Test every public state transition.
A record with-copy shares a mutable list.
- Why it happens
- The copy is shallow.
- How to fix it
- Copy nested state or use immutable element design.
- Prevent it next time
- Document ownership beside record properties.
A derived member behaves differently through a base reference.
- Why it happens
- The member was hidden rather than overridden.
- How to fix it
- Use the intended virtual/override contract.
- Prevent it next time
- Test polymorphic calls through the declared interface or base type.
Check your understanding
1. Does an init-only property holding List<int> make the list immutable? Explain a stronger contract.
Show worked solution
No. It restricts replacing the property through ordinary initialization rules, while the referenced list can still be mutated through an alias. For immutable behavior, use an appropriate immutable representation or privately copy input and expose operations that do not leak mutable storage. A read-only interface alone does not prove no other alias can mutate the data.
2. Design a counter test for int.MaxValue that checks the invariant and failure policy.
Show worked solution
Construct a counter at int.MaxValue, call TryAdvance, require false, and verify Value is unchanged. Also test zero and a normal value. The guard must run before the increment. These cases prove the advertised boundaries without relying on unchecked overflow or catching an accidental exception as normal behavior.
Key takeaways
- Constructors and operations should preserve one clear invariant.
- Interfaces need semantic promises as well as matching members.
- Records, init, and read-only interfaces do not automatically imply deep immutability.