Skip to content

Chapter 9: Records - Immutable Data for Chat History (Messages)

Theoretical Foundations

The concept of immutability is not merely a syntactic convenience; it is a foundational architectural principle for building robust, concurrent, and predictable AI systems. In the context of chat history, where messages are generated, routed, and processed by various asynchronous agents, ensuring that data remains constant after creation is critical to preventing race conditions and maintaining the integrity of the conversation state.

To understand the utility of C# record types, we must first contrast them with the mutable class structures introduced in earlier chapters. In Book 1, we established that reference types (classes) allow for state modification via their properties. While powerful, this mutability introduces significant risk in distributed systems. Consider a scenario where a ChatMessage object is passed to a logging service and simultaneously to a vectorization service. If the logging service modifies the message content (e.g., redacting sensitive data), the vectorization service will process corrupted, unintended data. This is a classic side-effect bug.

The record type in C# solves this by enforcing immutability at the compiler level. It is a reference type that provides built-in support for value-based equality and non-destructive mutation.

The Analogy: The Stone Tablet vs. The Whiteboard

Imagine a collaborative AI project using a Whiteboard (a mutable class). You write a message on the board. A colleague erases a word and writes a correction. Later, you return to reference your original message, but it has been altered. The history is lost, and your context is broken.

Now, imagine using Stone Tablets (a record). You carve a message onto a tablet. Once carved, it cannot be changed. If a correction is needed, you do not erase the original; you create a new tablet with the corrected text while keeping the original intact. This creates an immutable audit trail. In AI systems, where context windows rely on the precise sequence of tokens, preserving the "original stone tablet" is essential for deterministic behavior.

A record is a nominal type that synthesizes several features: immutability, value-based equality, and a concise syntax for constructing immutable data. Unlike a class, a record focuses on the data rather than the behavior.

1. Immutability and init Accessors

In a standard class, properties typically use get; set; accessors, allowing modification after instantiation. A record uses init accessors, which restrict assignment to the object initialization phase.

using System;

// A standard mutable class (Book 1 concept)
public class MutableMessage
{
    public string Content { get; set; } // Can be changed anytime
    public DateTime Timestamp { get; set; }
}

// An immutable record (Book 2 concept)
public record ImmutableMessage
{
    public string Content { get; init; } // Can only be set during creation
    public DateTime Timestamp { get; init; }
}

Architectural Implication: By using init, we guarantee that once an ImmutableMessage enters the chat history collection, its state is frozen. This allows us to safely pass references to this object across multiple AI agents (e.g., a summarizer, a sentiment analyzer, and a storage indexer) without fear that one agent will alter the data seen by another.

2. Value-Based Equality

In previous chapters, we discussed reference equality. Two class instances are equal only if they point to the same memory address. Records, however, implement value-based equality. Two record instances are considered equal if all their property values are equal.

var msg1 = new ImmutableMessage { Content = "Hello", Timestamp = DateTime.Now };
var msg2 = new ImmutableMessage { Content = "Hello", Timestamp = msg1.Timestamp };

// Reference equality (false for classes, false for records if different instances)
bool refEq = object.ReferenceEquals(msg1, msg2); 

// Value equality (false for classes, TRUE for records)
bool valueEq = msg1.Equals(msg2); 

Why this matters for AI: In complex systems, we often deduplicate incoming messages or check if a specific context has already been processed. Value equality allows us to compare the semantic content of messages without needing to track specific object instances.

3. Non-Destructive Mutation (With)

One of the most powerful features of records is the with expression. While immutability ensures safety, we often need to create a modified copy of an object (e.g., adding metadata to a message without altering the original). The with expression creates a new record instance, copying all values from the original, and applying modifications to specific properties.

var original = new ImmutableMessage 
{ 
    Content = "Identify this image", 
    Timestamp = DateTime.UtcNow 
};

// Create a new record with added metadata
var withMetadata = original with 
{ 
    // We keep Content and Timestamp from 'original'
    // We add a new property (assuming the record definition was extended)
    // Metadata = "HighPriority" 
};

// 'original' remains unchanged.

Architectural Implication: This is vital for "prompt engineering" pipelines. You might receive a base user message and need to wrap it with system instructions or metadata for a specific model (like GPT-4) without mutating the raw user input stored in the database.

Pattern Matching for Message Routing

Pattern matching allows us to inspect the structure and data of a record to determine control flow. This is superior to traditional if-else chains or virtual method dispatch when dealing with heterogeneous message types in a chat history.

In AI systems, we often route messages to different processing pipelines based on their content or type. Pattern matching enables concise and readable routing logic.

public record UserMessage(string Text, string UserId) : ImmutableMessage;
public record SystemMessage(string Instruction) : ImmutableMessage;
public record ImageMessage(byte[] ImageData, string Caption) : ImmutableMessage;

public void RouteMessage(ImmutableMessage msg)
{
    // Pattern matching on the record type and properties
    switch (msg)
    {
        case UserMessage u when u.Text.Length > 1000:
            Console.WriteLine("Routing to LongContextProcessor");
            break;

        case UserMessage u:
            Console.WriteLine("Routing to StandardChatProcessor");
            break;

        case ImageMessage img:
            Console.WriteLine("Routing to VisionModelProcessor");
            break;

        case SystemMessage sys:
            Console.WriteLine("Ignoring system instruction in history");
            break;

        default:
            Console.WriteLine("Unknown message type");
            break;
    }
}

Explicit Reference to Previous Concepts: This builds upon the Polymorphism concept from Book 1, where ImmutableMessage serves as a base type. However, pattern matching allows us to inspect the data (like u.Text.Length) and the type simultaneously without needing to add virtual methods to the record itself, keeping the data structure pure.

Generic Constraints for Typed Message Payloads

In advanced AI applications, a message might carry different payload types—a string for text, a tensor representation for embeddings, or a structured object for tool calls. We can use C# Generics to create a generic record wrapper that enforces type safety on the payload.

This allows us to build a unified history system that can handle diverse data types while maintaining strict compile-time checks.

// A generic record wrapping a payload of type T
public record TypedMessage<T>(T Payload, DateTime Timestamp);

// Usage with different types
var textMsg = new TypedMessage<string>("Hello AI", DateTime.UtcNow);
var tensorMsg = new TypedMessage<float[]>(new float[] { 0.1f, 0.5f }, DateTime.UtcNow);

// We can define constraints to ensure T is serializable or meets specific criteria
public record ConstrainedMessage<T>(T Payload) where T : ISerializable;

AI Application: This is crucial for Retrieval-Augmented Generation (RAG). We might store a TypedMessage<Vector<float>> containing the embedding of a user query. When a new query arrives, we can compare the stored vectors directly within the record structure, ensuring type safety and preventing runtime errors when attempting to cast objects.

Performance Comparison: Records vs. Classes

While immutability provides safety, it introduces specific performance characteristics that must be understood for high-throughput AI systems.

  1. Memory Allocation:

    • Classes: Mutable classes can be updated in place. If you change a property, no new memory is allocated.
    • Records: The with expression creates a new instance. In a high-frequency chat loop, creating thousands of new record instances per second can increase pressure on the Garbage Collector (GC).
  2. Equality Checks:

    • Classes: Reference equality is extremely fast (a single pointer comparison).
    • Records: Value equality requires iterating over all properties to compare them. For a record with 50 properties, this is significantly slower than reference equality.
  3. Thread Safety:

    • Classes: Mutable classes require locks (lock statements) or complex synchronization primitives to be thread-safe.
    • Records: Immutable records are inherently thread-safe. They can be shared across threads without locking mechanisms.

Architectural Trade-off: For chat history, where data is written once (when the message is generated) and read many times (by various models and UI components), the read-heavy nature favors Records. The cost of allocation during writes is acceptable to gain the benefit of lock-free reads.

Visualization of Data Flow

The following diagram illustrates how immutable records flow through an AI system compared to mutable objects.

The diagram contrasts the linear, versioned flow of immutable records through an AI system with the complex, concurrent modifications typical of mutable objects.
Hold "Ctrl" to enable pan & zoom

The diagram contrasts the linear, versioned flow of immutable records through an AI system with the complex, concurrent modifications typical of mutable objects.

Theoretical Foundations

The record type is not just syntactic sugar; it is a paradigm shift from object-oriented state management to functional data modeling. By enforcing immutability, we eliminate a vast class of concurrency bugs common in multi-agent AI systems. By leveraging value equality and pattern matching, we simplify the logic required to route and analyze chat data. While there is a trade-off in memory allocation, the gains in data integrity and system predictability make records the superior choice for structuring chat history in complex AI architectures.

Basic Code Example

using System;

// A simple, immutable Record to represent a chat message in a conversational AI system.
// This structure ensures data integrity and thread-safety when messages are shared across
// multiple processing threads (e.g., for logging, vectorization, or response generation).
public record ChatMessage
{
    // Properties are immutable. They can only be set during initialization via the constructor.
    // This prevents accidental modification after the message is created.
    public string Sender { get; }
    public string Content { get; }
    public DateTime Timestamp { get; }

    // Constructor to initialize the immutable properties.
    // This is the only way to set the state of the record.
    public ChatMessage(string sender, string content)
    {
        // Basic validation to ensure data integrity.
        if (string.IsNullOrWhiteSpace(sender))
            throw new ArgumentException("Sender cannot be null or empty.", nameof(sender));

        if (string.IsNullOrWhiteSpace(content))
            throw new ArgumentException("Content cannot be null or empty.", nameof(content));

        Sender = sender;
        Content = content;
        // Assign the current UTC time to ensure consistency across time zones.
        Timestamp = DateTime.UtcNow;
    }

    // Override ToString() for better debugging and logging output.
    public override string ToString()
    {
        return $"[{Timestamp:HH:mm:ss}] {Sender}: {Content}";
    }
}

// A generic container for a chat session, demonstrating how Records can be composed.
// Generics are allowed in Book 2, enabling flexible type safety for different message payloads.
public record ChatSession<TMessage> where TMessage : ChatMessage
{
    public string SessionId { get; }
    public List<TMessage> Messages { get; } // Using List<T> for collection management.

    public ChatSession(string sessionId)
    {
        if (string.IsNullOrWhiteSpace(sessionId))
            throw new ArgumentException("Session ID cannot be null or empty.", nameof(sessionId));

        SessionId = sessionId;
        Messages = new List<TMessage>();
    }

    // Method to add a message to the session.
    // Note: We are not modifying the existing record, but rather the internal list state.
    // In a purely functional approach, we would return a new ChatSession instance.
    // For this intermediate example, we demonstrate state mutation within the session container.
    public void AddMessage(TMessage message)
    {
        if (message == null)
            throw new ArgumentNullException(nameof(message));

        Messages.Add(message);
    }
}

class Program
{
    static void Main()
    {
        // Problem Context:
        // We are building a logging system for an AI chatbot. 
        // We need to capture user inputs and bot responses immutably to prevent 
        // race conditions when multiple threads access the chat history.

        try
        {
            // 1. Create an immutable ChatMessage instance.
            // The 'new' keyword invokes the primary constructor.
            ChatMessage userMessage = new ChatMessage("User", "Hello, can you explain Records?");

            // 2. Create a second message for the bot's response.
            ChatMessage botMessage = new ChatMessage("AI Assistant", "Records are immutable data structures.");

            // 3. Create a typed ChatSession to hold these messages.
            // We specify the type as ChatMessage (the base record).
            ChatSession<ChatMessage> session = new ChatSession<ChatMessage>("session-123");

            // 4. Add messages to the session.
            session.AddMessage(userMessage);
            session.AddMessage(botMessage);

            // 5. Display the session contents.
            Console.WriteLine($"Session ID: {session.SessionId}");
            foreach (var msg in session.Messages)
            {
                // Implicitly calls the overridden ToString() method.
                Console.WriteLine(msg.ToString());
            }

            // 6. Demonstrate Immutability:
            // The following line would cause a compilation error because properties are init-only.
            // userMessage.Sender = "Hacker"; // Error: Property or indexer cannot be assigned to -- it is read only

            // 7. Demonstrate Pattern Matching (Concept Preview):
            // While advanced pattern matching is covered later, we can check types safely here.
            if (userMessage is ChatMessage cm)
            {
                Console.WriteLine($"Type check passed: {cm.Sender}");
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

Explanation of the Code

  1. Record Definition (ChatMessage):

    • We define a record named ChatMessage. In C#, a record is a reference type that provides built-in immutability and value-based equality.
    • Properties: Sender, Content, and Timestamp are defined without setters (e.g., get; only). This makes them immutable after construction.
    • Constructor: We explicitly define a constructor to enforce initialization of required fields. We also include validation logic to ensure data integrity.
    • ToString() Override: Records come with a default ToString() implementation, but we override it to provide a custom format suitable for logging.
  2. Generic Record (ChatSession<TMessage>):

    • We introduce a generic record ChatSession<TMessage> to demonstrate how Records handle generic constraints.
    • The constraint where TMessage : ChatMessage ensures that the session can only contain messages that are of type ChatMessage or derived from it.
    • This structure allows for type-safe management of chat histories.
  3. Execution Flow (Main):

    • Instantiation: We create instances of ChatMessage using the new keyword and passing arguments to the primary constructor.
    • Collection Management: We instantiate ChatSession and add messages to its internal List<T>. Note that while the messages are immutable, the session itself manages a mutable collection of these immutable items.
    • Output: We iterate through the messages and print them to the console.

Common Pitfalls

  1. Attempting to Modify Properties After Initialization:

    • Mistake: Trying to assign a value to a property after the record has been created (e.g., userMessage.Sender = "New Name";).
    • Consequence: This will result in a compilation error (CS0200: Property or indexer cannot be assigned to -- it is read only).
    • Solution: If you need a modified version of the record, use the with expression (e.g., var updatedMessage = userMessage with { Sender = "New Name" };), which creates a new record instance with the specified changes.
  2. Null Arguments in Constructor:

    • Mistake: Passing null or empty strings to the ChatMessage constructor.
    • Consequence: The validation logic throws an ArgumentException, preventing the creation of an invalid object.
    • Solution: Always validate inputs in the constructor to maintain data integrity.

Visualizing the Data Structure

The following diagram illustrates the relationship between the ChatSession and the immutable ChatMessage records.

The diagram depicts a ChatSession class that composes a collection of immutable ChatMessage records, highlighting the constructor's role in validating inputs to ensure data integrity.
Hold "Ctrl" to enable pan & zoom

The diagram depicts a `ChatSession` class that composes a collection of immutable `ChatMessage` records, highlighting the constructor's role in validating inputs to ensure data integrity.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.