Skip to main content

3.4 - Encapsulation

Encapsulation is one of the four pillars of object-oriented programming. It is the technique of bundling data (attributes) and methods (behaviors) that operate on the data into a single unit (class), and restricting access to the internal state of the object. Encapsulation helps to hide the implementation details of an object and only expose what is necessary for the outside world to interact with it.

3.4.1 - Access Modifiers in Detail

Access modifiers control the visibility and accessibility of classes, methods, properties, and fields. C# provides several access modifiers to implement encapsulation, each serving a specific purpose in controlling access to your code.

Public

The public access modifier makes a member accessible from any code, both within the same assembly and from external assemblies.

/// <summary>
/// Demonstrates the use of public access modifier.
/// </summary>
public class PublicExample
{
/// <summary>
/// A public field that can be accessed from anywhere.
/// </summary>
/// <remarks>
/// Note: In real-world applications, public fields are generally avoided in favor of properties.
/// </remarks>
public int PublicField;

/// <summary>
/// A public method that can be called from anywhere.
/// </summary>
public void PublicMethod()
{
Console.WriteLine("This is a public method");
}
}

/// <summary>
/// Demonstrates how to use the PublicExample class.
/// </summary>
public class PublicExampleUsage
{
public void DemonstratePublicAccess()
{
// Create an instance of PublicExample
PublicExample example = new PublicExample();

// Access the public field directly
example.PublicField = 10; // Accessible from any code
Console.WriteLine($"PublicField value: {example.PublicField}");

// Call the public method
example.PublicMethod(); // Accessible from any code
}
}

When to Use Public Access

Use the public access modifier when:

  • You want to expose functionality to all other code
  • The member is part of the class's public API
  • The member needs to be accessible from derived classes in other assemblies
  • You're defining interfaces or public contract methods

Private

The private access modifier makes a member accessible only within the containing class or struct. This is the most restrictive access level and is fundamental to encapsulation.

/// <summary>
/// Demonstrates the use of private access modifier.
/// </summary>
public class PrivateExample
{
/// <summary>
/// A private field that can only be accessed within this class.
/// </summary>
private int _privateField;

/// <summary>
/// A private method that can only be called within this class.
/// </summary>
private void PrivateMethod()
{
Console.WriteLine("This is a private method");
}

/// <summary>
/// A public method that demonstrates access to private members.
/// </summary>
public void AccessPrivateMembers()
{
// Private members are accessible within the same class
_privateField = 10;
Console.WriteLine($"_privateField value: {_privateField}");

PrivateMethod();
}

/// <summary>
/// Another public method showing that private members are accessible throughout the class.
/// </summary>
public void AnotherMethod()
{
// Private members are accessible from any method within the same class
_privateField = 20;
PrivateMethod();
}
}

/// <summary>
/// Demonstrates how to use the PrivateExample class.
/// </summary>
public class PrivateExampleUsage
{
public void DemonstratePrivateAccess()
{
PrivateExample example = new PrivateExample();

// The following lines would cause compilation errors if uncommented:
// example._privateField = 10; // Error: '_privateField' is inaccessible due to its protection level
// example.PrivateMethod(); // Error: 'PrivateMethod' is inaccessible due to its protection level

// We can only access the public methods
example.AccessPrivateMembers(); // This works because the method itself is public
}
}

When to Use Private Access

Use the private access modifier when:

  • The member should only be accessible within the containing class
  • You want to hide implementation details
  • The member represents internal state that should not be directly manipulated
  • You want to enforce access through controlled interfaces (methods or properties)

Protected

The protected access modifier makes a member accessible within the containing class and by all derived classes, regardless of which assembly they are in. This is crucial for inheritance scenarios.

/// <summary>
/// Demonstrates the use of protected access modifier.
/// </summary>
public class BaseClass
{
/// <summary>
/// A protected field that can be accessed by this class and any derived classes.
/// </summary>
protected int _protectedField;

/// <summary>
/// A protected method that can be called by this class and any derived classes.
/// </summary>
protected void ProtectedMethod()
{
Console.WriteLine("This is a protected method");
}

/// <summary>
/// A public method that accesses protected members.
/// </summary>
public void AccessFromBase()
{
// Protected members are accessible within the class that defines them
_protectedField = 100;
ProtectedMethod();
Console.WriteLine($"_protectedField in base: {_protectedField}");
}
}

/// <summary>
/// A class that inherits from BaseClass, demonstrating protected member access.
/// </summary>
public class DerivedClass : BaseClass
{
/// <summary>
/// A public method that accesses protected members from the base class.
/// </summary>
public void AccessProtectedMembers()
{
// Protected members from the base class are accessible in derived classes
_protectedField = 10;
Console.WriteLine($"_protectedField in derived: {_protectedField}");

ProtectedMethod();
}

/// <summary>
/// A method that demonstrates how protected members can be used in derived class logic.
/// </summary>
public void ExtendBaseClassFunctionality()
{
// We can build upon the protected members of the base class
ProtectedMethod();
Console.WriteLine("Extended functionality in derived class");
}
}

/// <summary>
/// Demonstrates how to use the BaseClass and DerivedClass.
/// </summary>
public class ProtectedExampleUsage
{
public void DemonstrateProtectedAccess()
{
// Create instances of both classes
BaseClass baseObj = new BaseClass();
DerivedClass derivedObj = new DerivedClass();

// The following lines would cause compilation errors if uncommented:
// baseObj._protectedField = 10; // Error: '_protectedField' is inaccessible due to its protection level
// baseObj.ProtectedMethod(); // Error: 'ProtectedMethod' is inaccessible due to its protection level

// We can access public methods that internally use protected members
baseObj.AccessFromBase();

// The derived class can access protected members from the base class
derivedObj.AccessProtectedMembers();
derivedObj.ExtendBaseClassFunctionality();
}
}

When to Use Protected Access

Use the protected access modifier when:

  • You want to allow derived classes to access the member
  • The member represents functionality that derived classes should be able to use or override
  • You're designing a class hierarchy where certain implementation details should be shared
  • You want to provide a base implementation that derived classes can extend

Internal

The internal access modifier makes a member accessible within the same assembly but not from external assemblies. This is useful for components that should be shared within a project but not exposed publicly.

/// <summary>
/// Demonstrates the use of internal access modifier.
/// This class is only accessible within the same assembly.
/// </summary>
internal class InternalClass
{
/// <summary>
/// An internal field that can be accessed from any code within the same assembly.
/// </summary>
internal int InternalField;

/// <summary>
/// An internal method that can be called from any code within the same assembly.
/// </summary>
internal void InternalMethod()
{
Console.WriteLine("This is an internal method");
Console.WriteLine($"InternalField value: {InternalField}");
}

/// <summary>
/// A private method that demonstrates encapsulation within an internal class.
/// </summary>
private void PrivateMethodInInternalClass()
{
Console.WriteLine("This is a private method in an internal class");
// Still only accessible within this class, despite the class being internal
}
}

/// <summary>
/// Demonstrates how to use the InternalClass from within the same assembly.
/// </summary>
public class InternalExampleUsage
{
/// <summary>
/// Shows how internal members can be accessed from within the same assembly.
/// </summary>
public void DemonstrateInternalAccess()
{
// We can create an instance of InternalClass because we're in the same assembly
InternalClass obj = new InternalClass();

// We can access internal members from within the same assembly
obj.InternalField = 10;
obj.InternalMethod();

// But we still can't access private members of the internal class
// obj.PrivateMethodInInternalClass(); // Error: 'PrivateMethodInInternalClass' is inaccessible
}
}

/*
* The following code would be in a different assembly (project) and would not compile:
*
* namespace DifferentAssembly
* {
* public class ExternalUsage
* {
* public void AttemptToAccessInternalClass()
* {
* // Error: 'InternalClass' is inaccessible due to its protection level
* InternalClass obj = new InternalClass();
*
* // Error: 'InternalField' is inaccessible due to its protection level
* obj.InternalField = 10;
*
* // Error: 'InternalMethod' is inaccessible due to its protection level
* obj.InternalMethod();
* }
* }
* }
*/

When to Use Internal Access

Use the internal access modifier when:

  • The member should be accessible within your application or library, but not to external code
  • You're implementing functionality that should be shared across classes in your project
  • You want to hide implementation details from users of your library
  • You're creating helper classes that should not be part of the public API

Protected Internal

The protected internal access modifier combines the features of protected and internal. It makes a member accessible within the same assembly OR from derived classes in any assembly. This is less restrictive than either protected or internal alone.

/// <summary>
/// Demonstrates the use of protected internal access modifier.
/// </summary>
public class BaseWithProtectedInternal
{
/// <summary>
/// A protected internal field that can be accessed from within the same assembly
/// or from any derived class, even in different assemblies.
/// </summary>
protected internal int ProtectedInternalField;

/// <summary>
/// A protected internal method that can be called from within the same assembly
/// or from any derived class, even in different assemblies.
/// </summary>
protected internal void ProtectedInternalMethod()
{
Console.WriteLine("This is a protected internal method");
Console.WriteLine($"ProtectedInternalField value: {ProtectedInternalField}");
}

/// <summary>
/// A public method that uses the protected internal members.
/// </summary>
public void UseProtectedInternalMembers()
{
ProtectedInternalField = 100;
ProtectedInternalMethod();
}
}

/// <summary>
/// A class in the same assembly that uses the protected internal members.
/// </summary>
public class SameAssemblyAccess
{
/// <summary>
/// Demonstrates access to protected internal members from within the same assembly.
/// </summary>
public void AccessProtectedInternalMembers()
{
BaseWithProtectedInternal obj = new BaseWithProtectedInternal();

// We can access protected internal members from within the same assembly
// even though this class doesn't inherit from BaseWithProtectedInternal
obj.ProtectedInternalField = 10;
obj.ProtectedInternalMethod();
}
}

/// <summary>
/// A derived class in the same assembly.
/// </summary>
public class DerivedInSameAssembly : BaseWithProtectedInternal
{
/// <summary>
/// Demonstrates access to protected internal members from a derived class.
/// </summary>
public void AccessAsChild()
{
// We can access protected internal members because we inherit from BaseWithProtectedInternal
ProtectedInternalField = 20;
ProtectedInternalMethod();
}
}

/*
* The following code would be in a different assembly (project):
*
* namespace DifferentAssembly
* {
* // This would compile - derived classes can access protected internal members
* public class DerivedInDifferentAssembly : BaseWithProtectedInternal
* {
* public void AccessFromDerivedInOtherAssembly()
* {
* // We can access protected internal members because we inherit from BaseWithProtectedInternal
* // even though we're in a different assembly
* ProtectedInternalField = 30;
* ProtectedInternalMethod();
* }
* }
*
* // This would not compile for the protected internal members
* public class NonDerivedInDifferentAssembly
* {
* public void AttemptToAccess()
* {
* BaseWithProtectedInternal obj = new BaseWithProtectedInternal();
*
* // Error: 'ProtectedInternalField' is inaccessible due to its protection level
* obj.ProtectedInternalField = 10;
*
* // Error: 'ProtectedInternalMethod' is inaccessible due to its protection level
* obj.ProtectedInternalMethod();
*
* // But we can still access public members
* obj.UseProtectedInternalMembers();
* }
* }
* }
*/

When to Use Protected Internal Access

Use the protected internal access modifier when:

  • You want to allow access from both derived classes (regardless of assembly) and from within the same assembly
  • You're creating a framework where certain functionality should be available to extenders and internal components
  • You need to share implementation details with both derived classes and other classes in your library

Private Protected

The private protected access modifier (introduced in C# 7.2) makes a member accessible only within the containing class OR derived classes within the same assembly. This is more restrictive than protected internal.

/// <summary>
/// Demonstrates the use of private protected access modifier.
/// </summary>
public class BaseWithPrivateProtected
{
/// <summary>
/// A private protected field that can be accessed only from this class
/// or from derived classes within the same assembly.
/// </summary>
private protected int PrivateProtectedField;

/// <summary>
/// A private protected method that can be called only from this class
/// or from derived classes within the same assembly.
/// </summary>
private protected void PrivateProtectedMethod()
{
Console.WriteLine("This is a private protected method");
Console.WriteLine($"PrivateProtectedField value: {PrivateProtectedField}");
}

/// <summary>
/// A public method that uses the private protected members.
/// </summary>
public void UsePrivateProtectedMembers()
{
PrivateProtectedField = 100;
PrivateProtectedMethod();
}
}

/// <summary>
/// A derived class in the same assembly that can access private protected members.
/// </summary>
public class DerivedWithAccess : BaseWithPrivateProtected
{
/// <summary>
/// Demonstrates access to private protected members from a derived class in the same assembly.
/// </summary>
public void AccessPrivateProtectedMembers()
{
// We can access private protected members because:
// 1. We inherit from BaseWithPrivateProtected
// 2. We are in the same assembly
PrivateProtectedField = 10;
PrivateProtectedMethod();
}
}

/// <summary>
/// A non-derived class in the same assembly that cannot access private protected members.
/// </summary>
public class NonDerivedClass
{
/// <summary>
/// Demonstrates that private protected members are not accessible from non-derived classes.
/// </summary>
public void AttemptToAccessPrivateProtectedMembers()
{
BaseWithPrivateProtected obj = new BaseWithPrivateProtected();

// The following lines would cause compilation errors if uncommented:
// obj.PrivateProtectedField = 10; // Error: 'PrivateProtectedField' is inaccessible
// obj.PrivateProtectedMethod(); // Error: 'PrivateProtectedMethod' is inaccessible

// We can still access public members
obj.UsePrivateProtectedMembers();
}
}

/*
* The following code would be in a different assembly (project):
*
* namespace DifferentAssembly
* {
* // Even derived classes in different assemblies cannot access private protected members
* public class DerivedInDifferentAssembly : BaseWithPrivateProtected
* {
* public void AttemptToAccess()
* {
* // Error: 'PrivateProtectedField' is inaccessible due to its protection level
* // PrivateProtectedField = 30;
*
* // Error: 'PrivateProtectedMethod' is inaccessible due to its protection level
* // PrivateProtectedMethod();
*
* // But we can still access public members
* UsePrivateProtectedMembers();
* }
* }
* }
*/

When to Use Private Protected Access

Use the private protected access modifier when:

  • You want to restrict access to derived classes within the same assembly only
  • You need to share implementation details with derived classes, but want to prevent access from derived classes in other assemblies
  • You're creating a library where certain functionality should only be available to internal extenders

Access Modifier Summary

Here's a summary of C# access modifiers from most restrictive to least restrictive:

Access ModifierWithin ClassWithin AssemblyDerived Classes (Same Assembly)Derived Classes (Different Assembly)Outside Assembly
private
private protected
internal
protected
protected internal
public

3.4.2 - Properties as Encapsulation Mechanism

Properties provide a powerful way to encapsulate fields by controlling access to them through getters and setters. This allows you to validate input, compute values on the fly, and maintain object invariants while providing a clean, field-like syntax for client code.

Basic Property Syntax and Types

C# offers several types of properties to support different encapsulation needs:

/// <summary>
/// Demonstrates various types of properties in C# for encapsulation.
/// </summary>
public class Person
{
// Private backing fields - the encapsulated data
private string _name;
private int _age;
private string _password;

/// <summary>
/// Gets or sets the person's name with validation.
/// </summary>
/// <exception cref="ArgumentException">Thrown when value is null or empty.</exception>
public string Name
{
get
{
// Simply return the backing field
return _name;
}
set
{
// Validate the input before storing it
if (string.IsNullOrEmpty(value))
{
throw new ArgumentException("Name cannot be null or empty");
}
_name = value;
}
}

/// <summary>
/// Gets or sets the person's age with validation.
/// </summary>
/// <exception cref="ArgumentException">Thrown when value is negative.</exception>
public int Age
{
get { return _age; }
set
{
// Validate the input to maintain object invariants
if (value < 0)
{
throw new ArgumentException("Age cannot be negative");
}
_age = value;
}
}

/// <summary>
/// Gets a value indicating whether the person is an adult (18 or older).
/// This is a computed property with no backing field.
/// </summary>
public bool IsAdult
{
get { return _age >= 18; } // Computed from other data
}

/// <summary>
/// Sets the person's password (write-only for security).
/// </summary>
public string Password
{
// No getter - this is a write-only property
set
{
// In a real application, you would hash the password
_password = value;
}
}

/// <summary>
/// Gets or sets the person's email address.
/// This is an auto-implemented property (C# creates the backing field automatically).
/// </summary>
public string Email { get; set; }

/// <summary>
/// Gets or sets the registration date with a default value.
/// Auto-implemented property with initializer (C# 6+).
/// </summary>
public DateTime RegistrationDate { get; set; } = DateTime.Now;

/// <summary>
/// Gets the unique identifier for this person.
/// Read-only auto-implemented property (C# 6+).
/// </summary>
public Guid Id { get; } = Guid.NewGuid();

/// <summary>
/// Gets a value indicating whether the person's identity has been verified.
/// Property with private setter - can only be modified within the class.
/// </summary>
public bool IsVerified { get; private set; }

/// <summary>
/// Marks the person as verified.
/// </summary>
public void Verify()
{
// This method can modify the IsVerified property because the setter is private
IsVerified = true;
}

/// <summary>
/// Gets the person's full name with age.
/// Expression-bodied property (C# 6+) - a more concise syntax for read-only properties.
/// </summary>
public string FullName => $"{_name} (Age: {_age})";

/// <summary>
/// Gets or sets the normalized name (uppercase, trimmed).
/// Expression-bodied property with getter and setter (C# 7+).
/// </summary>
public string NormalizedName
{
// Null-conditional operator (?.) to handle null values
get => _name?.ToUpper();

// Transforms the input before storing it
set => _name = value?.Trim();
}

/// <summary>
/// Gets the person's birth year (calculated from age).
/// </summary>
public int BirthYear => DateTime.Now.Year - _age;

/// <summary>
/// Gets or sets the person's address.
/// Property with backing field and default value.
/// </summary>
private string _address = "Unknown";
public string Address
{
get => _address;
set => _address = string.IsNullOrEmpty(value) ? "Unknown" : value;
}
}

Using Properties

The following example demonstrates how to use the various property types:

// Create a new Person object
Person person = new Person();

// Setting properties with validation
person.Name = "John"; // Uses the setter with validation
// person.Name = ""; // Would throw ArgumentException: "Name cannot be null or empty"

person.Age = 30; // Uses the setter with validation
// person.Age = -5; // Would throw ArgumentException: "Age cannot be negative"

// Using auto-implemented properties
person.Email = "john@example.com";
person.Address = "123 Main St";

// Getting properties
Console.WriteLine($"Name: {person.Name}"); // Output: Name: John
Console.WriteLine($"Age: {person.Age}"); // Output: Age: 30
Console.WriteLine($"Is Adult: {person.IsAdult}"); // Output: Is Adult: True (computed property)
Console.WriteLine($"Birth Year: {person.BirthYear}"); // Output: Birth Year: 1994 (calculated property)
Console.WriteLine($"Email: {person.Email}"); // Output: Email: john@example.com
Console.WriteLine($"Address: {person.Address}"); // Output: Address: 123 Main St
Console.WriteLine($"Registration Date: {person.RegistrationDate}"); // Output: Registration Date: [current date/time]
Console.WriteLine($"ID: {person.Id}"); // Output: ID: [a new GUID]
Console.WriteLine($"Is Verified: {person.IsVerified}"); // Output: Is Verified: False
Console.WriteLine($"Full Name: {person.FullName}"); // Output: Full Name: John (Age: 30)
Console.WriteLine($"Normalized Name: {person.NormalizedName}"); // Output: Normalized Name: JOHN

// Setting a write-only property
person.Password = "secret"; // Uses the write-only property setter
// Console.WriteLine(person.Password); // Error: The property 'Password' has no getter

// Using a property with a private setter
Console.WriteLine($"Before verification - Is Verified: {person.IsVerified}"); // Output: Is Verified: False
person.Verify(); // Call method that changes the property with private setter
Console.WriteLine($"After verification - Is Verified: {person.IsVerified}"); // Output: Is Verified: True

// person.IsVerified = false; // Error: The property setter is inaccessible

// Using the expression-bodied property with setter
person.NormalizedName = " jane doe "; // Will be trimmed and stored as "jane doe"
Console.WriteLine($"Name after setting NormalizedName: {person.Name}"); // Output: Name: jane doe
Console.WriteLine($"NormalizedName: {person.NormalizedName}"); // Output: NormalizedName: JANE DOE

Property Types Explained

  1. Standard Properties with Backing Fields

    • Example: Name, Age
    • Provide complete control over getting and setting values
    • Allow validation, transformation, and side effects
    • Require explicit backing fields
  2. Computed Properties

    • Example: IsAdult, BirthYear
    • Calculate values based on other properties or fields
    • No backing field needed
    • Read-only (typically)
  3. Auto-Implemented Properties

    • Example: Email
    • Compiler generates the backing field automatically
    • Concise syntax for simple properties
    • Limited control over getting/setting (no validation)
  4. Auto-Implemented Properties with Initializers

    • Example: RegistrationDate
    • Set default values directly in the property declaration
    • Initialized when the object is created
  5. Read-Only Properties

    • Example: Id, IsAdult, FullName
    • Can only be read, not modified from outside the class
    • Can be set in constructors or initializers (for auto-implemented ones)
  6. Write-Only Properties

    • Example: Password
    • Can only be set, not read from outside the class
    • Useful for security-sensitive information
  7. Properties with Access-Modified Accessors

    • Example: IsVerified (public getter, private setter)
    • Different access levels for getter and setter
    • Allows controlled modification
  8. Expression-Bodied Properties

    • Example: FullName, NormalizedName
    • Concise syntax for simple property implementations
    • Available for both getters and setters (C# 7+)

3.4.3 - Readonly Fields

Readonly fields provide another important encapsulation mechanism in C#. They can only be assigned a value during declaration or in a constructor, creating immutable fields that cannot be changed after object initialization. This helps ensure that certain values remain constant throughout an object's lifetime.

/// <summary>
/// Demonstrates the use of readonly fields for creating immutable data.
/// </summary>
public class ImmutableExample
{
/// <summary>
/// A readonly field assigned at declaration.
/// This value can never be changed after initialization.
/// </summary>
public readonly int ConstantValue = 42;

/// <summary>
/// A readonly field assigned in the constructor.
/// </summary>
public readonly string Name;

/// <summary>
/// A readonly field assigned in the constructor.
/// </summary>
public readonly DateTime CreationTime;

/// <summary>
/// A readonly field with a complex initialization.
/// </summary>
public readonly List<string> ImmutableList;

/// <summary>
/// A static readonly field that is shared across all instances.
/// </summary>
public static readonly string AppVersion = "1.0.0";

/// <summary>
/// Initializes a new instance of the ImmutableExample class.
/// </summary>
/// <param name="name">The name to assign to the readonly Name field.</param>
public ImmutableExample(string name)
{
// Readonly fields can be assigned in constructors
Name = name;
CreationTime = DateTime.Now;

// We can initialize complex readonly fields
ImmutableList = new List<string> { "Item 1", "Item 2", "Item 3" };

// Note: We cannot reassign AppVersion here because it's static readonly
// and must be initialized at declaration
}

/// <summary>
/// Demonstrates that readonly fields cannot be changed after initialization.
/// </summary>
public void TryToChangeReadonlyField()
{
// The following lines would cause compilation errors if uncommented:

// ConstantValue = 100; // Error: Cannot assign to 'ConstantValue' because it is readonly
// Name = "New Name"; // Error: Cannot assign to 'Name' because it is readonly
// CreationTime = DateTime.Now; // Error: Cannot assign to 'CreationTime' because it is readonly
// ImmutableList = new List<string>(); // Error: Cannot assign to 'ImmutableList' because it is readonly

// However, if the readonly field contains a reference type, the object's contents can still be modified:
ImmutableList.Add("New Item"); // This is allowed! The list itself is readonly, but its contents are not
}

/// <summary>
/// Creates a truly immutable list by using a read-only wrapper.
/// </summary>
/// <returns>A read-only version of the list.</returns>
public IReadOnlyList<string> GetTrulyImmutableList()
{
return ImmutableList.AsReadOnly(); // Returns a read-only wrapper around the list
}
}

Using Readonly Fields

// Create an instance of ImmutableExample
ImmutableExample example = new ImmutableExample("Example Object");

// Access the readonly fields
Console.WriteLine($"Constant Value: {example.ConstantValue}"); // Output: Constant Value: 42
Console.WriteLine($"Name: {example.Name}"); // Output: Name: Example Object
Console.WriteLine($"Creation Time: {example.CreationTime}"); // Output: Creation Time: [current date/time]
Console.WriteLine($"App Version: {ImmutableExample.AppVersion}"); // Output: App Version: 1.0.0

// Display the initial list contents
Console.WriteLine("\nInitial list contents:");
foreach (string item in example.ImmutableList)
{
Console.WriteLine($"- {item}");
}

// The following lines would cause compilation errors if uncommented:
// example.ConstantValue = 100; // Error: Cannot assign to 'ConstantValue' because it is readonly
// example.Name = "New Name"; // Error: Cannot assign to 'Name' because it is readonly
// example.CreationTime = DateTime.Now; // Error: Cannot assign to 'CreationTime' because it is readonly
// example.ImmutableList = new List<string>(); // Error: Cannot assign to 'ImmutableList' because it is readonly

// However, we can still modify the contents of the list
example.ImmutableList.Add("Item 4");
example.ImmutableList.Add("Item 5");

// Display the modified list contents
Console.WriteLine("\nModified list contents (reference contents can be changed):");
foreach (string item in example.ImmutableList)
{
Console.WriteLine($"- {item}");
}

// Get a truly immutable list
IReadOnlyList<string> trulyImmutableList = example.GetTrulyImmutableList();

// The following line would cause a compilation error if uncommented:
// trulyImmutableList.Add("Item 6"); // Error: 'IReadOnlyList<string>' does not contain a definition for 'Add'

Key Points About Readonly Fields

  1. Initialization Restrictions:

    • Readonly fields can only be assigned values:
      • At declaration time
      • In a constructor
      • In a variable initializer
  2. Immutability:

    • For value types (like int, double, DateTime), readonly ensures complete immutability
    • For reference types (like List<T>, string[], custom classes), the reference is immutable, but the object's contents may still be mutable
  3. Static Readonly Fields:

    • Static readonly fields must be initialized at declaration
    • They cannot be assigned in constructors (since they belong to the class, not instances)
  4. Benefits:

    • Prevents accidental modification of important values
    • Makes code more predictable and easier to reason about
    • Supports thread safety by ensuring certain values never change
    • Communicates design intent clearly
  5. Readonly vs. Const:

    • readonly fields can be initialized at runtime (e.g., with the current date)
    • const fields must be initialized with compile-time constants
    • readonly can be used with any type
    • const can only be used with primitive types and string
  6. True Immutability:

    • For true immutability of collection types, use:
      • IReadOnlyList<T>, IReadOnlyCollection<T>, IReadOnlyDictionary<TKey, TValue>
      • System.Collections.Immutable namespace (e.g., ImmutableList<T>, ImmutableArray<T>)
      • Custom immutable types

3.4.4 - Immutable Classes

Immutable classes represent a complete approach to encapsulation where the entire object's state cannot be changed after creation. They are particularly useful for creating thread-safe objects, representing value-like entities, and simplifying code by eliminating state changes.

Designing Immutable Classes

To create a truly immutable class, follow these principles:

  1. Make all fields readonly
  2. Initialize all fields in the constructor
  3. Provide only getter properties (no setters)
  4. Return new instances instead of modifying the current instance
  5. Ensure any reference type fields are also immutable or protected from modification
/// <summary>
/// Demonstrates an immutable class design pattern.
/// Once created, instances of this class cannot be modified.
/// </summary>
public class ImmutablePerson
{
// Private readonly fields - cannot be changed after initialization
private readonly string _firstName;
private readonly string _lastName;
private readonly int _age;
private readonly DateTime _birthDate;
private readonly IReadOnlyList<string> _hobbies;

/// <summary>
/// Initializes a new instance of the ImmutablePerson class.
/// This is the only way to set the object's state.
/// </summary>
/// <param name="firstName">The person's first name.</param>
/// <param name="lastName">The person's last name.</param>
/// <param name="age">The person's age.</param>
/// <param name="birthDate">The person's birth date.</param>
/// <param name="hobbies">The person's hobbies (optional).</param>
/// <exception cref="ArgumentException">
/// Thrown when firstName or lastName is null or empty, or when age is negative.
/// </exception>
public ImmutablePerson(string firstName, string lastName, int age, DateTime birthDate, IEnumerable<string> hobbies = null)
{
// Validate input parameters
if (string.IsNullOrEmpty(firstName))
throw new ArgumentException("First name cannot be null or empty", nameof(firstName));

if (string.IsNullOrEmpty(lastName))
throw new ArgumentException("Last name cannot be null or empty", nameof(lastName));

if (age < 0)
throw new ArgumentException("Age cannot be negative", nameof(age));

// Initialize all fields
_firstName = firstName;
_lastName = lastName;
_age = age;
_birthDate = birthDate;

// Create a defensive copy of the collection to ensure immutability
_hobbies = hobbies != null
? new List<string>(hobbies).AsReadOnly()
: new List<string>().AsReadOnly();
}

// Read-only properties - no setters
/// <summary>Gets the person's first name.</summary>
public string FirstName => _firstName;

/// <summary>Gets the person's last name.</summary>
public string LastName => _lastName;

/// <summary>Gets the person's age.</summary>
public int Age => _age;

/// <summary>Gets the person's birth date.</summary>
public DateTime BirthDate => _birthDate;

/// <summary>Gets the person's full name.</summary>
public string FullName => $"{_firstName} {_lastName}";

/// <summary>Gets the person's hobbies as a read-only list.</summary>
public IReadOnlyList<string> Hobbies => _hobbies;

// Methods that return new instances instead of modifying the current instance
/// <summary>
/// Creates a new ImmutablePerson with a different first name.
/// </summary>
/// <param name="newFirstName">The new first name.</param>
/// <returns>A new ImmutablePerson instance with the updated first name.</returns>
public ImmutablePerson WithFirstName(string newFirstName)
{
return new ImmutablePerson(newFirstName, _lastName, _age, _birthDate, _hobbies);
}

/// <summary>
/// Creates a new ImmutablePerson with a different last name.
/// </summary>
/// <param name="newLastName">The new last name.</param>
/// <returns>A new ImmutablePerson instance with the updated last name.</returns>
public ImmutablePerson WithLastName(string newLastName)
{
return new ImmutablePerson(_firstName, newLastName, _age, _birthDate, _hobbies);
}

/// <summary>
/// Creates a new ImmutablePerson with a different age.
/// </summary>
/// <param name="newAge">The new age.</param>
/// <returns>A new ImmutablePerson instance with the updated age.</returns>
public ImmutablePerson WithAge(int newAge)
{
return new ImmutablePerson(_firstName, _lastName, newAge, _birthDate, _hobbies);
}

/// <summary>
/// Creates a new ImmutablePerson with different hobbies.
/// </summary>
/// <param name="newHobbies">The new hobbies.</param>
/// <returns>A new ImmutablePerson instance with the updated hobbies.</returns>
public ImmutablePerson WithHobbies(IEnumerable<string> newHobbies)
{
return new ImmutablePerson(_firstName, _lastName, _age, _birthDate, newHobbies);
}

/// <summary>
/// Creates a new ImmutablePerson with an additional hobby.
/// </summary>
/// <param name="hobby">The hobby to add.</param>
/// <returns>A new ImmutablePerson instance with the added hobby.</returns>
public ImmutablePerson AddHobby(string hobby)
{
if (string.IsNullOrEmpty(hobby))
return this;

var newHobbies = new List<string>(_hobbies) { hobby };
return new ImmutablePerson(_firstName, _lastName, _age, _birthDate, newHobbies);
}

/// <summary>
/// Returns a string that represents the current object.
/// </summary>
/// <returns>A string representation of the person.</returns>
public override string ToString()
{
return $"{FullName}, Age: {Age}, Born: {BirthDate:d}, " +
$"Hobbies: {(_hobbies.Count > 0 ? string.Join(", ", _hobbies) : "none")}";
}
}

Using Immutable Classes

// Create an immutable person
var birthDate = new DateTime(1993, 5, 15);
var person = new ImmutablePerson("John", "Doe", 30, birthDate,
new[] { "Reading", "Hiking" });

// Display the original person
Console.WriteLine("Original person:");
Console.WriteLine(person);
Console.WriteLine($"Full name: {person.FullName}");
Console.WriteLine($"Age: {person.Age}");
Console.WriteLine("Hobbies:");
foreach (var hobby in person.Hobbies)
{
Console.WriteLine($"- {hobby}");
}

// Creating new instances with modified values (the original remains unchanged)
Console.WriteLine("\nCreating modified instances:");

// Change first name
var person2 = person.WithFirstName("Jane");
Console.WriteLine($"Modified first name: {person2.FullName}, Age: {person2.Age}");

// Change last name
var person3 = person.WithLastName("Smith");
Console.WriteLine($"Modified last name: {person3.FullName}, Age: {person3.Age}");

// Change age
var person4 = person.WithAge(35);
Console.WriteLine($"Modified age: {person4.FullName}, Age: {person4.Age}");

// Add a hobby
var person5 = person.AddHobby("Swimming");
Console.WriteLine($"Added hobby: {person5.FullName}, Hobbies: {string.Join(", ", person5.Hobbies)}");

// Chaining modifications
var person6 = person
.WithFirstName("Jane")
.WithLastName("Smith")
.WithAge(35)
.AddHobby("Swimming");

Console.WriteLine($"Multiple modifications: {person6}");

// Original person remains unchanged
Console.WriteLine("\nOriginal person (unchanged):");
Console.WriteLine(person);

// The following would cause compilation errors if uncommented:
// person.FirstName = "Jane"; // Error: Property has no setter
// person.Age = 35; // Error: Property has no setter
// person.Hobbies.Add("Swimming"); // Error: Cannot modify a read-only collection

Benefits of Immutable Classes

  1. Thread Safety:

    • Immutable objects are inherently thread-safe
    • Multiple threads can access them simultaneously without synchronization
    • No risk of race conditions or inconsistent state
  2. Predictability:

    • Once created, an immutable object's state never changes
    • Eliminates temporal coupling (dependencies on the order of operations)
    • Simplifies debugging and reasoning about code
  3. Security:

    • Prevents unauthorized state changes
    • Useful for representing sensitive data that shouldn't be modified
  4. Caching and Memoization:

    • Immutable objects can be safely cached and reused
    • Hash codes remain consistent, making them reliable keys in hash-based collections
  5. Functional Programming Style:

    • Enables a more functional approach to programming
    • Encourages methods that transform data rather than mutate state

Immutable Collections

For working with collections in an immutable way, C# provides the System.Collections.Immutable namespace:

using System.Collections.Immutable;

// Creating immutable collections
ImmutableList<int> list = ImmutableList.Create<int>(1, 2, 3, 4, 5);
ImmutableDictionary<string, int> dict = ImmutableDictionary.Create<string, int>()
.Add("one", 1)
.Add("two", 2)
.Add("three", 3);

// Creating new collections from existing ones
ImmutableList<int> newList = list.Add(6); // Original list is unchanged
ImmutableDictionary<string, int> newDict = dict.SetItem("two", 22); // Original dict is unchanged

// The original collections remain unchanged
Console.WriteLine(string.Join(", ", list)); // Output: 1, 2, 3, 4, 5
Console.WriteLine(string.Join(", ", newList)); // Output: 1, 2, 3, 4, 5, 6
Console.WriteLine(dict["two"]); // Output: 2
Console.WriteLine(newDict["two"]); // Output: 22

Records: Built-in Immutable Types (C# 9+)

In C# 9 and later, you can use records to create immutable reference types with simplified syntax:

// Immutable record type with positional parameters
public record PersonRecord(string FirstName, string LastName, int Age, DateTime BirthDate);

// Usage
var person1 = new PersonRecord("John", "Doe", 30, new DateTime(1993, 5, 15));
Console.WriteLine(person1); // PersonRecord { FirstName = John, LastName = Doe, Age = 30, BirthDate = 5/15/1993 }

// Creating a new record with modified properties using 'with' expression
var person2 = person1 with { FirstName = "Jane", Age = 28 };
Console.WriteLine(person2); // PersonRecord { FirstName = Jane, LastName = Doe, Age = 28, BirthDate = 5/15/1993 }

// Value-based equality
var person3 = new PersonRecord("John", "Doe", 30, new DateTime(1993, 5, 15));
Console.WriteLine(person1 == person3); // True - records use value-based equality

3.4.5 - Data Hiding

Data hiding is a fundamental aspect of encapsulation. It involves hiding the internal state of an object and providing access to it only through controlled interfaces (methods and properties). This technique protects the integrity of the object's state and allows the implementation to change without affecting client code.

Comprehensive Example: Banking System

The following example demonstrates data hiding in a banking system:

/// <summary>
/// Represents a bank account with deposit, withdrawal, and transfer capabilities.
/// Demonstrates data hiding by keeping all state private and exposing controlled interfaces.
/// </summary>
public class BankAccount
{
// Private fields - hidden from outside code
private readonly string _accountNumber;
private decimal _balance;
private readonly List<Transaction> _transactions;
private readonly decimal _overdraftLimit;
private bool _isFrozen;

/// <summary>
/// Initializes a new instance of the BankAccount class.
/// </summary>
/// <param name="accountNumber">The account number.</param>
/// <param name="initialDeposit">The initial deposit amount.</param>
/// <param name="overdraftLimit">The maximum allowed overdraft (defaults to 0).</param>
/// <exception cref="ArgumentException">
/// Thrown when accountNumber is null or empty, or when initialDeposit is negative.
/// </exception>
public BankAccount(string accountNumber, decimal initialDeposit, decimal overdraftLimit = 0)
{
// Validate input parameters
if (string.IsNullOrEmpty(accountNumber))
{
throw new ArgumentException("Account number cannot be null or empty", nameof(accountNumber));
}

if (initialDeposit < 0)
{
throw new ArgumentException("Initial deposit cannot be negative", nameof(initialDeposit));
}

if (overdraftLimit < 0)
{
throw new ArgumentException("Overdraft limit cannot be negative", nameof(overdraftLimit));
}

// Initialize private fields
_accountNumber = accountNumber;
_balance = initialDeposit;
_transactions = new List<Transaction>();
_overdraftLimit = overdraftLimit;
_isFrozen = false;

// Record the initial deposit as a transaction if it's greater than zero
if (initialDeposit > 0)
{
RecordTransaction(initialDeposit, "Initial deposit");
}
}

// Public properties - controlled access to internal state

/// <summary>
/// Gets the account number.
/// </summary>
public string AccountNumber => _accountNumber;

/// <summary>
/// Gets the current balance.
/// </summary>
public decimal Balance => _balance;

/// <summary>
/// Gets the overdraft limit.
/// </summary>
public decimal OverdraftLimit => _overdraftLimit;

/// <summary>
/// Gets a value indicating whether the account is frozen.
/// </summary>
public bool IsFrozen => _isFrozen;

/// <summary>
/// Gets the available balance (including overdraft).
/// </summary>
public decimal AvailableBalance => _balance + _overdraftLimit;

/// <summary>
/// Gets a read-only view of the transaction history.
/// </summary>
public IReadOnlyList<Transaction> Transactions => _transactions.AsReadOnly();

// Public methods - controlled operations on internal state

/// <summary>
/// Deposits the specified amount into the account.
/// </summary>
/// <param name="amount">The amount to deposit.</param>
/// <param name="description">A description of the deposit.</param>
/// <returns>true if the deposit was successful; otherwise, false.</returns>
/// <exception cref="ArgumentException">Thrown when amount is not positive.</exception>
public bool Deposit(decimal amount, string description)
{
// Validate the deposit amount
if (amount <= 0)
{
throw new ArgumentException("Deposit amount must be positive", nameof(amount));
}

// Check if the account is frozen
if (_isFrozen)
{
return false; // Cannot deposit to a frozen account
}

// Update the internal state
_balance += amount;

// Record the transaction
RecordTransaction(amount, description);

return true;
}

/// <summary>
/// Withdraws the specified amount from the account.
/// </summary>
/// <param name="amount">The amount to withdraw.</param>
/// <param name="description">A description of the withdrawal.</param>
/// <returns>true if the withdrawal was successful; otherwise, false.</returns>
/// <exception cref="ArgumentException">Thrown when amount is not positive.</exception>
public bool Withdraw(decimal amount, string description)
{
// Validate the withdrawal amount
if (amount <= 0)
{
throw new ArgumentException("Withdrawal amount must be positive", nameof(amount));
}

// Check if the account is frozen
if (_isFrozen)
{
return false; // Cannot withdraw from a frozen account
}

// Check if there are sufficient funds (including overdraft)
if (amount > _balance + _overdraftLimit)
{
return false; // Insufficient funds
}

// Update the internal state
_balance -= amount;

// Record the transaction
RecordTransaction(-amount, description);

return true;
}

/// <summary>
/// Transfers the specified amount to another account.
/// </summary>
/// <param name="recipient">The recipient account.</param>
/// <param name="amount">The amount to transfer.</param>
/// <param name="description">A description of the transfer.</param>
/// <returns>true if the transfer was successful; otherwise, false.</returns>
/// <exception cref="ArgumentNullException">Thrown when recipient is null.</exception>
/// <exception cref="ArgumentException">Thrown when amount is not positive.</exception>
public bool Transfer(BankAccount recipient, decimal amount, string description)
{
// Validate the recipient and amount
if (recipient == null)
{
throw new ArgumentNullException(nameof(recipient));
}

if (amount <= 0)
{
throw new ArgumentException("Transfer amount must be positive", nameof(amount));
}

// Check if either account is frozen
if (_isFrozen || recipient._isFrozen)
{
return false; // Cannot transfer to/from a frozen account
}

// Check if there are sufficient funds (including overdraft)
if (amount > _balance + _overdraftLimit)
{
return false; // Insufficient funds
}

// Update the internal state of both accounts
_balance -= amount;
recipient._balance += amount;

// Record the transactions in both accounts
RecordTransaction(-amount, $"Transfer to {recipient._accountNumber}: {description}");
recipient.RecordTransaction(amount, $"Transfer from {_accountNumber}: {description}");

return true;
}

/// <summary>
/// Freezes the account, preventing deposits and withdrawals.
/// </summary>
public void Freeze()
{
if (!_isFrozen)
{
_isFrozen = true;
RecordTransaction(0, "Account frozen");
}
}

/// <summary>
/// Unfreezes the account, allowing deposits and withdrawals.
/// </summary>
public void Unfreeze()
{
if (_isFrozen)
{
_isFrozen = false;
RecordTransaction(0, "Account unfrozen");
}
}

/// <summary>
/// Gets a summary of the account.
/// </summary>
/// <returns>A string containing the account summary.</returns>
public string GetAccountSummary()
{
return $"Account: {_accountNumber}\n" +
$"Balance: {_balance:C}\n" +
$"Available Balance: {AvailableBalance:C}\n" +
$"Status: {(_isFrozen ? "Frozen" : "Active")}\n" +
$"Transaction Count: {_transactions.Count}";
}

// Private method - internal implementation detail
/// <summary>
/// Records a transaction in the account's transaction history.
/// </summary>
/// <param name="amount">The transaction amount.</param>
/// <param name="description">The transaction description.</param>
private void RecordTransaction(decimal amount, string description)
{
// Create a new transaction and add it to the private list
var transaction = new Transaction(DateTime.Now, amount, description, _balance);
_transactions.Add(transaction);
}
}

/// <summary>
/// Represents an immutable bank transaction.
/// </summary>
public class Transaction
{
/// <summary>
/// Gets the date and time of the transaction.
/// </summary>
public DateTime Date { get; }

/// <summary>
/// Gets the amount of the transaction.
/// </summary>
public decimal Amount { get; }

/// <summary>
/// Gets the description of the transaction.
/// </summary>
public string Description { get; }

/// <summary>
/// Gets the balance after the transaction.
/// </summary>
public decimal BalanceAfter { get; }

/// <summary>
/// Initializes a new instance of the Transaction class.
/// </summary>
/// <param name="date">The date and time of the transaction.</param>
/// <param name="amount">The amount of the transaction.</param>
/// <param name="description">The description of the transaction.</param>
/// <param name="balanceAfter">The balance after the transaction.</param>
public Transaction(DateTime date, decimal amount, string description, decimal balanceAfter)
{
Date = date;
Amount = amount;
Description = description;
BalanceAfter = balanceAfter;
}

/// <summary>
/// Returns a string that represents the current transaction.
/// </summary>
/// <returns>A string representation of the transaction.</returns>
public override string ToString()
{
string type = Amount >= 0 ? "CREDIT" : "DEBIT";
return $"{Date:yyyy-MM-dd HH:mm:ss} | {type} | {Math.Abs(Amount):C} | {Description} | Balance: {BalanceAfter:C}";
}
}

Using Data Hiding in the Banking System

// Creating bank accounts
BankAccount checkingAccount = new BankAccount("CHK-123456", 1000, 500);
BankAccount savingsAccount = new BankAccount("SAV-789012", 5000);

// Display initial account information
Console.WriteLine("=== Initial Account Information ===");
Console.WriteLine(checkingAccount.GetAccountSummary());
Console.WriteLine();
Console.WriteLine(savingsAccount.GetAccountSummary());
Console.WriteLine();

// Accessing properties (read-only access to internal state)
Console.WriteLine("=== Account Properties ===");
Console.WriteLine($"Checking Account Number: {checkingAccount.AccountNumber}");
Console.WriteLine($"Checking Balance: {checkingAccount.Balance:C}");
Console.WriteLine($"Checking Available Balance: {checkingAccount.AvailableBalance:C}");
Console.WriteLine($"Checking Is Frozen: {checkingAccount.IsFrozen}");
Console.WriteLine();

// Performing operations through controlled interfaces
Console.WriteLine("=== Performing Transactions ===");

// Deposit
bool depositResult = checkingAccount.Deposit(750.50m, "Salary deposit");
Console.WriteLine($"Deposit result: {depositResult}");
Console.WriteLine($"New balance: {checkingAccount.Balance:C}");

// Withdrawal
bool withdrawResult = checkingAccount.Withdraw(1200, "Rent payment");
Console.WriteLine($"Withdrawal result: {withdrawResult}");
Console.WriteLine($"New balance: {checkingAccount.Balance:C}");

// Transfer
bool transferResult = checkingAccount.Transfer(savingsAccount, 300, "Monthly savings");
Console.WriteLine($"Transfer result: {transferResult}");
Console.WriteLine($"Checking balance: {checkingAccount.Balance:C}");
Console.WriteLine($"Savings balance: {savingsAccount.Balance:C}");

// Attempt to withdraw more than available (should fail)
bool overdraftResult = checkingAccount.Withdraw(2000, "Attempted large withdrawal");
Console.WriteLine($"Excessive withdrawal result: {overdraftResult}");

// Freeze account
checkingAccount.Freeze();
Console.WriteLine($"Account frozen: {checkingAccount.IsFrozen}");

// Attempt operation on frozen account (should fail)
bool frozenDepositResult = checkingAccount.Deposit(100, "Deposit to frozen account");
Console.WriteLine($"Deposit to frozen account result: {frozenDepositResult}");

// Unfreeze account
checkingAccount.Unfreeze();
Console.WriteLine($"Account unfrozen: {checkingAccount.IsFrozen}");

// Accessing transaction history
Console.WriteLine("\n=== Transaction History ===");
Console.WriteLine("Checking Account Transactions:");
foreach (Transaction transaction in checkingAccount.Transactions)
{
Console.WriteLine(transaction);
}

Console.WriteLine("\nSavings Account Transactions:");
foreach (Transaction transaction in savingsAccount.Transactions)
{
Console.WriteLine(transaction);
}

// The following lines would cause compilation errors if uncommented:
// checkingAccount._balance = 1000000; // Error: '_balance' is inaccessible
// checkingAccount._transactions.Add(new Transaction(DateTime.Now, 1000000, "Fake", 1000000)); // Error: '_transactions' is inaccessible
// checkingAccount.Transactions.Add(new Transaction(DateTime.Now, 1000, "Fake", 1000)); // Error: Cannot modify a read-only collection

Key Principles of Data Hiding

  1. Private State:

    • All internal data is stored in private fields
    • No direct access to the internal state from outside the class
    • Implementation details are hidden from client code
  2. Controlled Access:

    • Public properties provide read-only access to internal state
    • Public methods provide controlled ways to modify the state
    • Input validation ensures the object maintains a valid state
  3. Encapsulated Behavior:

    • Internal operations are implemented as private methods
    • Public methods use these private methods to perform operations
    • Implementation can change without affecting client code
  4. Information Hiding:

    • Only necessary information is exposed through the public interface
    • Implementation details are hidden from client code
    • Clients depend only on the public interface, not the implementation

Benefits of Data Hiding

  1. Maintainability:

    • Implementation can change without affecting client code
    • Bugs are easier to locate and fix
    • Code is more modular and easier to understand
  2. Reliability:

    • Object state is always valid due to controlled access
    • Invariants can be enforced through validation
    • Prevents accidental corruption of data
  3. Security:

    • Sensitive data can be protected from unauthorized access
    • Operations can be restricted based on state or permissions
    • Audit trails can be maintained automatically
  4. Flexibility:

    • Implementation can evolve over time
    • New features can be added without breaking existing code
    • Performance optimizations can be made transparently

3.4.6 - Encapsulation Best Practices

When implementing encapsulation in C#, consider the following best practices to create robust, maintainable, and secure code:

Core Encapsulation Principles

  1. Make Fields Private

    • Fields should generally be private and accessed through properties or methods
    • Use the naming convention _fieldName for private fields to distinguish them from local variables
    • Example: private string _firstName;
  2. Use Properties for Access Control

    • Properties provide a way to control access to fields, allowing for validation, transformation, and computed values
    • Consider using auto-implemented properties for simple cases: public string Name { get; set; }
    • Use full property implementation when validation or custom logic is needed
  3. Validate Input

    • Use property setters and method parameters to validate input and maintain invariants
    • Throw appropriate exceptions with meaningful messages when validation fails
    • Consider using guard clauses at the beginning of methods
  4. Expose Immutable Collections

    • When exposing collections, return read-only versions to prevent external modification
    • Use IReadOnlyList<T>, IReadOnlyCollection<T>, or AsReadOnly() method
    • Example: public IReadOnlyList<Order> Orders => _orders.AsReadOnly();
  5. Consider Immutability

    • For value-like objects, consider making them immutable to prevent unexpected changes
    • Use readonly fields, read-only properties, and methods that return new instances
    • Consider using C# records for simple immutable types

Advanced Encapsulation Techniques

  1. Use Access Modifiers Strategically

    • Choose the most restrictive access modifier that still allows the code to function
    • Consider using internal for classes that should only be used within the same assembly
    • Use protected only when derived classes genuinely need access to the member
  2. Implement Property Access Restrictions

    • Use private setters for properties that should only be modified internally
    • Example: public bool IsVerified { get; private set; }
    • Consider init-only properties (C# 9+) for properties that should only be set during initialization
  3. Encapsulate Related Functionality

    • Group related fields and methods into cohesive classes
    • Follow the Single Responsibility Principle (SRP)
    • Create helper classes for complex operations
  4. Document Public Interface

    • Use XML documentation comments to clearly document the public interface
    • Include information about exceptions that might be thrown
    • Document any assumptions or requirements
  5. Follow the Principle of Least Privilege

    • Expose only the minimum functionality required for clients to use the class effectively
    • Hide implementation details that clients don't need to know about
    • Avoid "leaky abstractions" that expose internal implementation details

Example of Comprehensive Encapsulation

The following example demonstrates a well-encapsulated e-commerce system with multiple classes:

/// <summary>
/// Represents a customer in the e-commerce system.
/// Demonstrates comprehensive encapsulation techniques.
/// </summary>
public class Customer
{
// Private fields with consistent naming convention
private string _firstName;
private string _lastName;
private string _email;
private readonly DateTime _registrationDate;
private readonly Guid _id;
private readonly List<Order> _orders;
private CustomerStatus _status;

/// <summary>
/// Initializes a new instance of the Customer class.
/// </summary>
/// <param name="firstName">The customer's first name.</param>
/// <param name="lastName">The customer's last name.</param>
/// <param name="email">The customer's email address.</param>
/// <exception cref="ArgumentException">
/// Thrown when firstName or lastName is null or empty, or when email is invalid.
/// </exception>
public Customer(string firstName, string lastName, string email)
{
// Validate input using guard clauses
if (string.IsNullOrEmpty(firstName))
throw new ArgumentException("First name cannot be null or empty", nameof(firstName));

if (string.IsNullOrEmpty(lastName))
throw new ArgumentException("Last name cannot be null or empty", nameof(lastName));

if (!IsValidEmail(email))
throw new ArgumentException("Invalid email address", nameof(email));

// Initialize fields
_firstName = firstName;
_lastName = lastName;
_email = email;
_registrationDate = DateTime.Now;
_id = Guid.NewGuid();
_orders = new List<Order>();
_status = CustomerStatus.New;
}

// Public properties with appropriate access control and validation

/// <summary>
/// Gets or sets the customer's first name.
/// </summary>
/// <exception cref="ArgumentException">Thrown when value is null or empty.</exception>
public string FirstName
{
get => _firstName;
set
{
if (string.IsNullOrEmpty(value))
throw new ArgumentException("First name cannot be null or empty");

_firstName = value;
}
}

/// <summary>
/// Gets or sets the customer's last name.
/// </summary>
/// <exception cref="ArgumentException">Thrown when value is null or empty.</exception>
public string LastName
{
get => _lastName;
set
{
if (string.IsNullOrEmpty(value))
throw new ArgumentException("Last name cannot be null or empty");

_lastName = value;
}
}

/// <summary>
/// Gets or sets the customer's email address.
/// </summary>
/// <exception cref="ArgumentException">Thrown when value is an invalid email address.</exception>
public string Email
{
get => _email;
set
{
if (!IsValidEmail(value))
throw new ArgumentException("Invalid email address");

_email = value;
}
}

/// <summary>
/// Gets the customer's status.
/// </summary>
public CustomerStatus Status => _status;

// Read-only properties - computed or immutable values

/// <summary>
/// Gets the customer's full name.
/// </summary>
public string FullName => $"{_firstName} {_lastName}";

/// <summary>
/// Gets the customer's registration date.
/// </summary>
public DateTime RegistrationDate => _registrationDate;

/// <summary>
/// Gets the customer's unique identifier.
/// </summary>
public Guid Id => _id;

/// <summary>
/// Gets a read-only view of the customer's orders.
/// </summary>
public IReadOnlyList<Order> Orders => _orders.AsReadOnly();

/// <summary>
/// Gets a value indicating whether the customer has any orders.
/// </summary>
public bool HasOrders => _orders.Count > 0;

/// <summary>
/// Gets the total amount spent by the customer.
/// </summary>
public decimal TotalSpent => _orders.Sum(o => o.Total);

/// <summary>
/// Gets the customer's loyalty level based on total spent.
/// </summary>
public LoyaltyLevel LoyaltyLevel
{
get
{
if (TotalSpent >= 1000)
return LoyaltyLevel.Platinum;
if (TotalSpent >= 500)
return LoyaltyLevel.Gold;
if (TotalSpent >= 100)
return LoyaltyLevel.Silver;
return LoyaltyLevel.Bronze;
}
}

// Public methods - controlled operations on internal state

/// <summary>
/// Adds an order to the customer's order history.
/// </summary>
/// <param name="order">The order to add.</param>
/// <exception cref="ArgumentNullException">Thrown when order is null.</exception>
public void AddOrder(Order order)
{
if (order == null)
throw new ArgumentNullException(nameof(order));

_orders.Add(order);

// Update customer status based on order count
if (_status == CustomerStatus.New)
_status = CustomerStatus.Active;
}

/// <summary>
/// Gets an order by its ID.
/// </summary>
/// <param name="orderId">The ID of the order to get.</param>
/// <returns>The order with the specified ID, or null if no such order exists.</returns>
public Order GetOrder(Guid orderId)
{
return _orders.FirstOrDefault(o => o.Id == orderId);
}

/// <summary>
/// Removes an order from the customer's order history.
/// </summary>
/// <param name="orderId">The ID of the order to remove.</param>
/// <returns>true if the order was removed; otherwise, false.</returns>
public bool RemoveOrder(Guid orderId)
{
Order order = GetOrder(orderId);
if (order != null)
{
return _orders.Remove(order);
}
return false;
}

/// <summary>
/// Upgrades the customer to VIP status.
/// </summary>
/// <remarks>
/// This is an example of a method that changes internal state in a controlled way.
/// </remarks>
public void UpgradeToVip()
{
_status = CustomerStatus.Vip;
}

/// <summary>
/// Deactivates the customer account.
/// </summary>
/// <remarks>
/// This is an example of a method that changes internal state in a controlled way.
/// </remarks>
public void Deactivate()
{
_status = CustomerStatus.Inactive;
}

/// <summary>
/// Returns a string that represents the current customer.
/// </summary>
/// <returns>A string containing the customer's name, email, and status.</returns>
public override string ToString()
{
return $"{FullName} ({Email}) - {Status}";
}

// Private helper methods - implementation details hidden from clients

/// <summary>
/// Validates an email address.
/// </summary>
/// <param name="email">The email address to validate.</param>
/// <returns>true if the email is valid; otherwise, false.</returns>
private bool IsValidEmail(string email)
{
// Simple validation for demonstration purposes
return !string.IsNullOrEmpty(email) && email.Contains("@") && email.Contains(".");
}
}

/// <summary>
/// Represents a customer's status in the system.
/// </summary>
public enum CustomerStatus
{
/// <summary>
/// A newly registered customer with no orders.
/// </summary>
New,

/// <summary>
/// A customer who has placed at least one order.
/// </summary>
Active,

/// <summary>
/// A customer with VIP privileges.
/// </summary>
Vip,

/// <summary>
/// A customer whose account has been deactivated.
/// </summary>
Inactive
}

/// <summary>
/// Represents a customer's loyalty level based on total spending.
/// </summary>
public enum LoyaltyLevel
{
/// <summary>
/// The basic loyalty level.
/// </summary>
Bronze,

/// <summary>
/// The silver loyalty level.
/// </summary>
Silver,

/// <summary>
/// The gold loyalty level.
/// </summary>
Gold,

/// <summary>
/// The platinum loyalty level.
/// </summary>
Platinum
}

/// <summary>
/// Represents an order in the e-commerce system.
/// </summary>
public class Order
{
// Private fields
private readonly Guid _id;
private readonly DateTime _orderDate;
private readonly List<OrderItem> _items;
private OrderStatus _status;

/// <summary>
/// Initializes a new instance of the Order class.
/// </summary>
public Order()
{
_id = Guid.NewGuid();
_orderDate = DateTime.Now;
_items = new List<OrderItem>();
_status = OrderStatus.Created;
}

// Read-only properties
/// <summary>Gets the order's unique identifier.</summary>
public Guid Id => _id;

/// <summary>Gets the date and time when the order was created.</summary>
public DateTime OrderDate => _orderDate;

/// <summary>Gets a read-only view of the order items.</summary>
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

/// <summary>Gets the total cost of the order.</summary>
public decimal Total => _items.Sum(i => i.Subtotal);

/// <summary>Gets the current status of the order.</summary>
public OrderStatus Status => _status;

// Public methods
/// <summary>
/// Adds an item to the order.
/// </summary>
/// <param name="name">The name of the item.</param>
/// <param name="price">The price of the item.</param>
/// <param name="quantity">The quantity of the item.</param>
/// <exception cref="ArgumentException">
/// Thrown when name is null or empty, price is not positive, or quantity is not positive.
/// </exception>
/// <exception cref="InvalidOperationException">
/// Thrown when attempting to modify a completed or cancelled order.
/// </exception>
public void AddItem(string name, decimal price, int quantity)
{
// Validate order state
if (_status == OrderStatus.Completed || _status == OrderStatus.Cancelled)
throw new InvalidOperationException($"Cannot modify an order with status: {_status}");

// Validate input
if (string.IsNullOrEmpty(name))
throw new ArgumentException("Item name cannot be null or empty", nameof(name));

if (price <= 0)
throw new ArgumentException("Price must be positive", nameof(price));

if (quantity <= 0)
throw new ArgumentException("Quantity must be positive", nameof(quantity));

// Add the item
_items.Add(new OrderItem(name, price, quantity));
}

/// <summary>
/// Processes the order, changing its status to Processing.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when the order is empty or already processed, completed, or cancelled.
/// </exception>
public void Process()
{
if (_items.Count == 0)
throw new InvalidOperationException("Cannot process an empty order");

if (_status != OrderStatus.Created)
throw new InvalidOperationException($"Cannot process an order with status: {_status}");

_status = OrderStatus.Processing;
}

/// <summary>
/// Completes the order, changing its status to Completed.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when the order is not in Processing status.
/// </exception>
public void Complete()
{
if (_status != OrderStatus.Processing)
throw new InvalidOperationException($"Cannot complete an order with status: {_status}");

_status = OrderStatus.Completed;
}

/// <summary>
/// Cancels the order, changing its status to Cancelled.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when the order is already completed.
/// </exception>
public void Cancel()
{
if (_status == OrderStatus.Completed)
throw new InvalidOperationException("Cannot cancel a completed order");

_status = OrderStatus.Cancelled;
}

/// <summary>
/// Returns a string that represents the current order.
/// </summary>
/// <returns>A string containing the order details.</returns>
public override string ToString()
{
return $"Order {_id} ({_orderDate:g}) - {_status}: {_items.Count} items, Total: {Total:C}";
}
}

/// <summary>
/// Represents the status of an order.
/// </summary>
public enum OrderStatus
{
/// <summary>
/// The order has been created but not yet processed.
/// </summary>
Created,

/// <summary>
/// The order is being processed.
/// </summary>
Processing,

/// <summary>
/// The order has been completed.
/// </summary>
Completed,

/// <summary>
/// The order has been cancelled.
/// </summary>
Cancelled
}

/// <summary>
/// Represents an item in an order.
/// Demonstrates immutability for simple value objects.
/// </summary>
public class OrderItem
{
// Private readonly fields for immutability
private readonly string _name;
private readonly decimal _price;
private readonly int _quantity;

/// <summary>
/// Initializes a new instance of the OrderItem class.
/// </summary>
/// <param name="name">The name of the item.</param>
/// <param name="price">The price of the item.</param>
/// <param name="quantity">The quantity of the item.</param>
public OrderItem(string name, decimal price, int quantity)
{
_name = name;
_price = price;
_quantity = quantity;
}

// Read-only properties
/// <summary>Gets the name of the item.</summary>
public string Name => _name;

/// <summary>Gets the price of the item.</summary>
public decimal Price => _price;

/// <summary>Gets the quantity of the item.</summary>
public int Quantity => _quantity;

/// <summary>Gets the subtotal for this item (price × quantity).</summary>
public decimal Subtotal => _price * _quantity;

/// <summary>
/// Returns a string that represents the current item.
/// </summary>
/// <returns>A string containing the item details.</returns>
public override string ToString()
{
return $"{_name} - {_quantity} × {_price:C} = {Subtotal:C}";
}
}

Using Well-Encapsulated Classes

// Creating a customer with validated input
try
{
// This will succeed
Customer customer = new Customer("John", "Doe", "john.doe@example.com");
Console.WriteLine($"Created customer: {customer}");

// These would throw exceptions if uncommented
// Customer invalidCustomer1 = new Customer("", "Doe", "john.doe@example.com"); // Empty first name
// Customer invalidCustomer2 = new Customer("John", "", "john.doe@example.com"); // Empty last name
// Customer invalidCustomer3 = new Customer("John", "Doe", "invalid-email"); // Invalid email

// Accessing read-only properties
Console.WriteLine($"ID: {customer.Id}");
Console.WriteLine($"Registration Date: {customer.RegistrationDate}");
Console.WriteLine($"Status: {customer.Status}");
Console.WriteLine($"Has Orders: {customer.HasOrders}");
Console.WriteLine($"Loyalty Level: {customer.LoyaltyLevel}");

// Modifying properties with validation
customer.FirstName = "Jane"; // This works
customer.Email = "jane.doe@example.com"; // This works
// customer.Email = "invalid"; // This would throw an exception
Console.WriteLine($"Updated customer: {customer}");

// Creating and processing orders
Order order1 = new Order();
order1.AddItem("Laptop", 999.99m, 1);
order1.AddItem("Mouse", 25.99m, 2);
order1.Process(); // Change status to Processing
order1.Complete(); // Change status to Completed
customer.AddOrder(order1);

Order order2 = new Order();
order2.AddItem("Headphones", 149.99m, 1);
order2.Process();
customer.AddOrder(order2);

// Accessing computed properties
Console.WriteLine($"Has Orders: {customer.HasOrders}");
Console.WriteLine($"Total Spent: {customer.TotalSpent:C}");
Console.WriteLine($"Updated Loyalty Level: {customer.LoyaltyLevel}");

// Accessing orders through the read-only collection
Console.WriteLine("\nOrders:");
foreach (Order order in customer.Orders)
{
Console.WriteLine(order);

Console.WriteLine("Items:");
foreach (OrderItem item in order.Items)
{
Console.WriteLine($" {item}");
}

Console.WriteLine();
}

// Changing customer status through controlled methods
customer.UpgradeToVip();
Console.WriteLine($"After upgrade: {customer}");

// The following lines would cause compilation errors if uncommented:
// customer._id = Guid.NewGuid(); // Error: '_id' is inaccessible
// customer._orders.Clear(); // Error: '_orders' is inaccessible
// customer.Orders.Clear(); // Error: Cannot modify a read-only collection
// customer.Status = CustomerStatus.Inactive; // Error: Property has no setter

// Try to modify a completed order (will throw an exception)
try
{
order1.AddItem("Keyboard", 49.99m, 1);
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Expected error: {ex.Message}");
}
}
catch (ArgumentException ex)
{
Console.WriteLine($"Validation error: {ex.Message}");
}

In the next section, we'll explore interfaces, a powerful feature in C# that enables polymorphism and helps define contracts for classes to implement.