Software Development

The End of the Mutable DTO: Why C# Records Are the Future of Data Contracts

For years, C# developers have relied on mutable classes with get; set; properties to represent Data Transfer Objects (DTOs). This ubiquitous pattern, ingrained since the .NET Framework 2.0 era, has served as the connective tissue for countless distributed systems. However, an uncomfortable truth has emerged: mutable classes are fundamentally the wrong data structure for DTOs, a fact underscored by the introduction of a superior alternative in C# 9. This isn’t a mere stylistic quibble; it’s a design flaw that manifests in tangible production incidents, from duplicate financial transactions to corrupted audit trails and insidious race conditions. This article will explore the foundational reasons why records are the idiomatic and safer choice for DTOs, drawing parallels with critical systems in banking and fintech, where the cost of errors is measured in significant financial losses, not just software bugs. Whether you’re a junior engineer crafting your first API contract or a seasoned architect designing a payment platform, understanding the "why" behind records is paramount.

What Are DTOs in Practice?

At its core, a DTO serves as a simple, structured data carrier, designed to efficiently transfer data across system boundaries. Consider the seemingly straightforward example of a ride-sharing application. When a user requests nearby drivers, the backend might return a DriverDto containing essential information:


  "id": 142,
  "name": "Linda",
  "rating": 4.9

In C#, this might traditionally be represented as:

public class DriverDto

    public int Id  get; set; 
    public string Name  get; set; 
    public double Rating  get; set; 

While functional, this class-based approach is inherently mutable and verbose. It doesn’t accurately reflect the true nature of a DTO: a static snapshot of data at a particular moment.

The real value of DTOs, however, shines in the complex landscapes of enterprise software, particularly within financial systems. In a payment platform, DTOs are indispensable for representing every facet of a transaction:

  • Payment Initiation: Details of a transaction being started.
  • Transaction Confirmation: Acknowledgment of successful payment processing.
  • Settlement Notifications: Updates on the final settlement of funds.
  • Reconciliation Records: Data points for matching internal and external ledgers.
  • Fraud Alerts: Information flagging potentially suspicious activity.
  • Audit Logs: Records of significant events for compliance and traceability.

Each of these represents a contract, a definitive statement of fact communicated across a boundary. The fundamental insight is this: A DTO is a snapshot of data at a point in time. Snapshots do not change. Therefore, why are we modeling them with a type whose defining characteristic is its mutability?

Imagine a printed bank statement. Once it leaves the printer, its contents are fixed. If a correction is needed, a new statement is issued. Mutable classes, with their set; accessors, are akin to handing out pens with every statement, inviting unauthorized alterations. Records, conversely, take the pen away, preserving the integrity of the data.

The Real Problems with Class-Based DTOs

The issues with mutable class-based DTOs extend beyond mere verbosity. They introduce concrete liabilities at system boundaries, leading to predictable failures:

1. Mutability is a Liability at Boundaries

Consider a PaymentNotificationDto flowing through a fintech platform. This object will traverse multiple services – validation, enrichment, fraud screening, ledger posting, and notification. These stages often involve asynchronous operations or even cross-thread communication. The inherent mutability of a class means that any of these stages can silently alter the DTO’s state.

A seemingly innocuous code change, such as normalizing an amount:

// "Normalizing" inside the fraud check – seems harmless
notification.Amount = Math.Round(notification.Amount, 2);

can have cascading consequences. The amount that eventually lands on the ledger is no longer the original amount received from the partner bank. In high-volume financial systems, a minuscule discrepancy of ₦0.005 across millions of transactions can lead to an end-of-day reconciliation nightmare, consuming valuable engineering hours to pinpoint the source of the alteration. The compiler, in this scenario, offers no protection. The immutable nature of financial data in transit is paramount, and mutability is an open invitation to tampering, whether accidental or malicious.

2. Reference Equality is the Wrong Equality

For all practical purposes, two DTOs carrying identical data represent the same "fact." However, standard C# classes compare by reference.

var a = new PaymentNotificationDto  TransactionRef = "TXN-8842", Amount = 50000m, Currency = "NGN" ;
var b = new PaymentNotificationDto  TransactionRef = "TXN-8842", Amount = 50000m, Currency = "NGN" ;

Console.WriteLine(a == b);        // false
Console.WriteLine(a.Equals(b));   // false

This discrepancy is critical for idempotency, a cornerstone of financial systems. Partner banks may retry webhooks, Kafka delivers messages at-least-once, and mobile applications resubmit requests on timeouts. The system must reliably recognize "I’ve processed this exact fact before." With mutable classes, achieving this requires either manually implementing Equals and GetHashCode (a common source of bugs when properties are added or removed without updating the equality logic) or resorting to less efficient methods like comparing serialized JSON.

3. The Boilerplate Hides the Contract

A class-based DTO, when properly implemented with immutability (via constructor injection), robust equality, and a useful ToString for logging, can easily balloon to 40-60 lines. The actual contract – the essential data structure – might only be 5 lines. The remaining 90% is boilerplate that must be maintained, reviewed, and kept synchronized. In an enterprise codebase with hundreds of DTOs, this amounts to tens of thousands of lines of code that serve no functional purpose beyond maintaining a legacy pattern, each line a potential hiding place for bugs.

4. Lack of Intent Communication

The declaration public class PaymentNotificationDto provides no inherent information about how the type is intended to be used. Is it safe to cache? Can it be shared across threads? Is mutation expected? This ambiguity leaves the responsibility of understanding usage patterns to tribal knowledge and code review vigilance – two of the least reliable enforcement mechanisms in software development.

Enter Records: The Type That Says What It Means

C# records offer a paradigm shift, elegantly addressing these shortcomings. The same PaymentNotificationDto transformed into a record:

public record PaymentNotificationDto(
    string TransactionRef,
    decimal Amount,
    string Currency,
    string SenderAccount,
    DateTime ReceivedAt);

This concise five-line definition automatically generates crucial functionality:

  • Value-based equality: a == b now correctly evaluates to true if the data is identical.
  • GetHashCode implementation: Essential for efficient use in hash-based collections.
  • ToString() override: Provides a structured, readable representation for logging.
  • Immutability: Properties are effectively init-only after construction.

The idempotency check becomes trivial:

var a = new PaymentNotificationDto("TXN-8842", 50000m, "NGN", "0123456789", timestamp);
var b = new PaymentNotificationDto("TXN-8842", 50000m, "NGN", "0123456789", timestamp);

Console.WriteLine(a == b); // true – same fact, recognized as such

Furthermore, the attempt to mutate an amount that previously caused a subtle bug now results in a compile-time error:

notification.Amount = Math.Round(notification.Amount, 2);
// CS8852: Init-only property can only be assigned in an object initializer

The with expression provides a controlled mechanism for creating modified copies, explicitly signaling a transformation:

var normalized = notification with  Amount = Math.Round(notification.Amount, 2) ;

This transformation from implicit mutation to explicit creation of a new state is invaluable in regulated environments where auditing the precise lineage of data is critical.

Records in the Wild: Enterprise Fintech Scenarios

Let’s examine how records address real-world challenges in enterprise fintech:

Scenario 1: Idempotent Webhook Ingestion

In a payment system, duplicate settlement notifications due to network unreliability can lead to direct financial losses. Records, with their inherent value equality, simplify deduplication:

public record SettlementNotification(
    string ProviderRef,
    string SessionId,
    decimal Amount,
    string Currency,
    string BeneficiaryAccount,
    DateTimeOffset SettledAt);

// In-memory dedup for a processing window – HashSet just works
private readonly HashSet<SettlementNotification> _seenInWindow = new();

public async Task HandleAsync(SettlementNotification notification)

    if (!_seenInWindow.Add(notification))
    
        _logger.LogWarning("Duplicate settlement ignored: Notification", notification);
        return; // Structured log includes full payload via generated ToString
    
    await _ledger.PostAsync(notification);

Using a HashSet<SettlementNotification> immediately benefits from the record’s correct GetHashCode and Equals implementation, preventing duplicate entries. With classes, this would necessitate error-prone manual equality implementations.

Scenario 2: Event-Driven Architecture on Kafka

In event-driven systems, events represent immutable facts about the past. Records align perfectly with this principle:

public record TransferInitiated(
    Guid TransferId,
    string SourceAccount,
    string DestinationAccount,
    decimal Amount,
    string Currency,
    DateTimeOffset InitiatedAt) : IDomainEvent;

When replaying events or building new projections, the guarantee that no handler could have mutated the event is crucial for deterministic outcomes. Records enforce this architectural rule at the compiler level.

Scenario 3: Maker-Checker Workflows

Regulated operations often employ a maker-checker model. Records, combined with the with expression, elegantly model state transitions:

public record ReversalRequest(
    Guid RequestId,
    string TransactionRef,
    decimal Amount,
    string MakerId,
    string? CheckerId,
    ApprovalStatus Status,
    DateTimeOffset CreatedAt,
    DateTimeOffset? DecidedAt);

public ReversalRequest Approve(ReversalRequest request, string checkerId) =>
    request with
    
        CheckerId = checkerId,
        Status = ApprovalStatus.Approved,
        DecidedAt = _clock.UtcNow
    ;

The original request remains untouched, and the approval process generates a new, distinct state. This immutability is vital for auditability, allowing for a clear before-and-after comparison of the request.

Scenario 4: End-of-Day Reconciliation

Reconciliation is fundamentally a set comparison problem. Records, with their value equality, seamlessly integrate with LINQ for efficient comparisons:

public record ReconRow(string Ref, decimal Amount, string Currency, DateOnly ValueDate);

var internalRows = await _ledger.GetRowsAsync(date);
var providerRows = ParseProviderFile(file);

var missingOnOurSide = providerRows.Except(internalRows);  // they have it, we don't
var missingOnTheirSide = internalRows.Except(providerRows);  // we have it, they don't

LINQ operations like Except, Intersect, and Distinct function correctly out-of-the-box with records, eliminating the need for custom IEqualityComparer implementations.

How Records Fulfill Software Engineering Principles

Records are more than just syntactic sugar; they embody fundamental software engineering principles:

  • Single Responsibility Principle (SRP): Records focus solely on data carriage, preventing them from becoming mutable workspaces that introduce side effects.
  • Open/Closed Principle (OCP): The with expression allows for extension (creating new states from existing ones) without modification, fostering a more predictable and testable pipeline.
  • Liskov Substitution Principle (LSP): Records offer hierarchy-aware equality, correctly distinguishing between base and derived types, though composition is generally preferred for DTOs.
  • Interface Segregation Principle (ISP): Wide positional records naturally encourage decomposition into smaller, more focused contracts through composition, making them easier to manage and understand.
  • Dependency Inversion Principle (DIP): Immutable record contracts provide stable abstractions, enhancing the reliability of inter-service communication.
  • YAGNI (You Aren’t Gonna Need It): Records provide only the necessary capabilities for data carriers, avoiding speculative mutability.
  • DRY (Don’t Repeat Yourself): By moving boilerplate equality and ToString logic to the compiler, records eliminate redundant code.
  • Principle of Least Astonishment: Record equality behaves intuitively, reducing unexpected behavior and debugging time.
  • Fail Fast: Positional records, when combined with constructor validation, ensure data integrity at the point of creation, preventing invalid states from propagating.
  • DDD: Records are Value Objects: Records are the language-level embodiment of the Value Object pattern from Domain-Driven Design, intrinsically designed for comparison by value.
  • CQS/CQRS Alignment: Immutable records perfectly align with the unidirectional flow of commands and queries in CQRS architectures, simplifying reasoning and testing.

Performance Considerations

While records primarily offer correctness and maintainability benefits, they also bring performance advantages:

  • Efficient Equality: Compiler-generated equality checks are direct and allocation-free, vastly outperforming reflection-based or serialization-based comparisons.
  • Optimized Collections: Correct GetHashCode enables O(1) lookups in dictionaries and hash sets.
  • Lock-Free Sharing: Immutability allows multiple threads to access records concurrently without synchronization overhead, significantly boosting throughput in high-TPS systems.
  • Elimination of Defensive Copies: The safety of passing immutable records reduces the need for costly defensive copies.
  • record struct for Hot Paths: For small, frequently used data structures, readonly record struct offers stack allocation and zero GC pressure.

However, it’s crucial to acknowledge the costs: with expressions allocate new instances, and record equality is field-dependent. These are typically minor concerns for DTOs but should be considered in extremely performance-critical, tight loops.

Limitations and Sharp Edges

While records are the superior choice for DTOs, they are not universally applicable:

  • EF Core Entities: ORM entities have identity and lifecycle management that conflicts with record semantics. Use classes for EF Core entities and map to records at the boundary.
  • Collection Properties: Mutable collections within records break value equality. Use immutable collections or override equality explicitly.
  • Shallow Copying with with: Nested mutable objects are not deep-copied by with, potentially leading to shared mutable state. Ensure immutability throughout nested structures.
  • Serializer/Framework Friction: Older tools may not fully support record binding.
  • Inheritance Equality: While safer than class-based equality, record inheritance can still surprise developers. Favor composition.
  • Wide Records: Records with many parameters become unwieldy. Decompose them or use nominal records with init properties.
  • Brownfield Migration: Migrating legacy mutable DTOs requires careful analysis to avoid introducing regressions.

When a Class Is Still the Right Answer

Classes remain appropriate for types that represent:

  • Actors: Entities with identity, lifecycle, and mutable behavior (e.g., services, domain aggregates, UI controllers).
  • Mutable Caches or Builders: Types intentionally designed for accumulation or modification within a specific scope.
  • EF Core Entities: As previously mentioned, their identity and change-tracking mechanisms necessitate mutable classes.

The guiding principle is simple: Records are for facts. Classes are for actors.

A Practical Decision Guide

Type of Thing Use
API request/response contracts record
Kafka / message-bus events record
MediatR commands, queries, results record
DDD value objects record (with constructor validation)
Small hot-path carriers readonly record struct
EF Core entities / aggregate roots class
Services, handlers, middleware class
Builders, accumulators, mutable caches class
Configuration (e.g., IOptions) record with init properties

Conclusion: This Was Never About Syntax

The record keyword offers a significant reduction in boilerplate, but its true value lies in its architectural implications. Mutable class DTOs necessitate discipline; records enforce correctness through compilation. In critical domains like finance, the cost of a mutated DTO transcends mere bugs, leading to tangible financial and regulatory repercussions. Every software engineering principle, from SRP to fail-fast and DDD value semantics, converges on the inherent suitability of immutable, value-comparable records for data crossing system boundaries. By embracing records, we take away the pen, let the compiler enforce integrity, and ultimately build more robust, maintainable, and secure systems.

Related Articles

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button
Lock It Soft
Privacy Overview

This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.