Introduction
When building APIs in .NET, the way you structure your data transfer objects directly affects how maintainable and testable your codebase becomes. Records make API contracts concise, immutable and comparable—qualities that elevate your code from functional to production-ready. In this guide, we'll walk through why records are the right choice for DTOs, how they simplify your testing strategy, and where to draw the boundary between entities and transfer objects.
Why records matter for DTOs
Before C# 9, writing a DTO meant boilerplate: properties, backing fields, equality overrides, hash codes. You'd end up with 30+ lines of code that said almost nothing about your domain. Records compress that ceremony into a declaration that's both readable and semantically clear.
A DTO's job is to carry data across a boundary—from your database to an API response, from a client request to your business logic. That's a narrow, focused role. Records align perfectly with this contract-first thinking because they enforce immutability by default and provide value-based equality without extra work.
Consider a typical order response. With a traditional class:
public class OrderDto
{
public int Id { get; init; }
public string Customer { get; init; }
public decimal Total { get; init; }
public override bool Equals(object obj) => obj is OrderDto dto &&
Id == dto.Id && Customer == dto.Customer && Total == dto.Total;
public override int GetHashCode() => HashCode.Combine(Id, Customer, Total);
}
With a record, the same contract looks like this:
public record OrderDto(int Id, string Customer, decimal Total);
One line. Everything else is implicit: immutability, equality, hashing, deconstruction. Your team reads the actual data shape immediately, without wading through ceremony.
One line per DTO with positional records
Positional records—the form above—are ideal when your DTO has a small, well-defined set of properties. They create a tight contract that's impossible to misread. The parameters become both the public properties and the constructor signature.
In a real e-commerce system, you might have several DTOs for different views of the same domain object:
// Product listing—minimal data
public record ProductListItemDto(int Id, string Name, decimal Price);
// Product detail—richer representation
public record ProductDetailDto(
int Id,
string Name,
string Description,
decimal Price,
int StockLevel,
DateTimeOffset CreatedAt);
// Cart line item—context-specific
public record CartLineDto(int ProductId, string ProductName, int Quantity, decimal UnitPrice);
Each record is scannable. A developer new to the codebase can see exactly what data flows through that boundary. When you need named properties instead of positional ones, or when you want better formatting in JSON responses, you can add explicit properties:
public record OrderDto(int Id, string Customer, decimal Total)
{
[JsonPropertyName("orderId")]
public int Id { get; init; } = Id;
public DateTime CreatedAt { get; init; }
}
That hybrid form is less common but useful when JSON serialization needs names that don't match your C# properties.
Value equality makes testing trivial
Testing API responses becomes dramatically simpler with records. Instead of writing custom assertion logic or relying on reflection-based equality, you compare instances directly.
Without records, asserting an API response looked like this:
var response = await client.GetAsync("/orders/123");
var dto = JsonSerializer.Deserialize<OrderDto>(await response.Content.ReadAsStringAsync());
Assert.Equal(123, dto.Id);
Assert.Equal("Acme Corp", dto.Customer);
Assert.Equal(1500m, dto.Total);
With records, you can do this:
var response = await client.GetAsync("/orders/123");
var dto = JsonSerializer.Deserialize<OrderDto>(await response.Content.ReadAsStringAsync());
var expected = new OrderDto(123, "Acme Corp", 1500m);
Assert.Equal(expected, dto);
The second form is more readable and catches any difference in the data structure, not just the fields you remembered to check. This becomes especially valuable in property-based testing, where you generate many instances and need reliable comparisons:
[Property]
public void OrderDto_RoundTrips_Through_Json(OrderDto original)
{
var json = JsonSerializer.Serialize(original);
var deserialized = JsonSerializer.Deserialize<OrderDto>(json);
Assert.Equal(original, deserialized);
}
No custom equality logic. No mocking helpers. Just compare instances and trust that records give you the right semantics.
With-expressions for safe copies
Immutable objects are safer in concurrent systems—no thread can mutate the data under another thread's feet. But immutability creates a challenge: how do you create a modified copy?
Records answer this with with-expressions. Instead of cloning and mutating, you declare which fields change:
public record OrderDto(int Id, string Customer, decimal Total);
// Original order
var order = new OrderDto(1, "Acme Corp", 1500m);
// Create a copy with a different customer
var updatedOrder = order with { Customer = "Acme Inc" };
// Create a copy with a different total
var discountedOrder = order with { Total = 1350m };
This pattern is invaluable in middleware or service layers where you transform DTOs. For example, a discount service might enrich an order:
public class DiscountService
{
public OrderDto ApplyDiscount(OrderDto order, decimal discountPercent)
{
var discountedTotal = order.Total * (1 - discountPercent / 100m);
return order with { Total = discountedTotal };
}
}
The original order is unchanged. Readers of this code immediately see which field changed and why. Compare this to:
// Without records, you'd either mutate (unsafe):
order.Total = discountedTotal;
return order;
// Or clone and mutate (verbose):
var copy = new OrderDto { Id = order.Id, Customer = order.Customer, Total = discountedTotal };
return copy;
With-expressions strike a balance: they're concise and immutable.
Keep entities as classes; DTOs as records
This is the boundary that matters most. DTOs and entities serve different purposes, and conflating them creates friction.
Entities represent your domain logic—orders, products, users. They have behavior, validation rules, and state transitions. They're mutable because business operations change them. They live in your domain layer and are tied to how you persist data.
DTOs are projection—views of data shaped for a specific boundary. An API client doesn't need to know about your entity's internal state or methods. A database migration shouldn't affect your API contract.
Keep this separation:
// Domain layer—mutable, behavior-rich
public class Order
{
public int Id { get; private set; }
public string CustomerId { get; private set; }
public List<OrderLine> Lines { get; private set; } = new();
public OrderStatus Status { get; private set; } = OrderStatus.Pending;
public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
public void AddLine(Product product, int quantity)
{
if (quantity <= 0) throw new ArgumentException("Quantity must be positive");
Lines.Add(new OrderLine(product, quantity));
}
public decimal CalculateTotal() => Lines.Sum(l => l.Subtotal);
public void Confirm()
{
if (Status != OrderStatus.Pending) throw new InvalidOperationException("Order not pending");
Status = OrderStatus.Confirmed;
}
}
// API layer—immutable, shape-specific
public record OrderDto(int Id, string Customer, decimal Total);
Your API endpoint maps the entity to the DTO:
[HttpGet("orders/{id}")]
public async Task<IActionResult> GetOrder(int id)
{
var order = await _orderService.GetAsync(id);
if (order == null) return NotFound();
var dto = new OrderDto(order.Id, order.Customer.Name, order.CalculateTotal());
return Ok(dto);
}
Now you can refactor your entity without touching the API. You can add business logic to the entity without bloating the DTO. The two live in separate worlds and only meet at the boundary.
Common mistakes
Using records for entities
It's tempting to make your domain objects records because they're concise. Don't. Entities need mutable state and behavior. Making them records forces you to use with for every state change, which becomes awkward at scale. Keep records in the application and API layers; keep classes in the domain.
Over-nesting records
A record that contains another record that contains another record can become hard to reason about, especially in JSON serialization. Flatten where possible. If your DTO needs deeply nested structures, ask whether the client actually needs that depth or if you're over-sharing internal structure.
Mutating with-expressions thinking they're safe
A with-expression creates a shallow copy. If your record contains a mutable reference type—a list, for example—that reference is shared:
public record OrderDto(int Id, List<LineItem> Lines);
var order1 = new OrderDto(1, new List<LineItem> { ... });
var order2 = order1 with { Id = 2 };
order2.Lines.Add(new LineItem(...)); // order1.Lines is also modified!
For DTOs, avoid mutable reference types in the positional parameters. Use immutable collections:
public record OrderDto(int Id, ImmutableList<LineItem> Lines);
Ignoring JSON serialization defaults
By default, System.Text.Json is case-sensitive. Your C# property CustomerId won't deserialize from JSON customerId unless you configure it. For consistency, use property naming policies at the serializer level or apply [JsonPropertyName] where needed.
Key takeaways
- Use positional records for DTOs to eliminate boilerplate while making the contract obvious to readers.
- Value equality on records makes unit tests simpler and more expressive—compare instances directly instead of checking individual fields.
- With-expressions provide safe, immutable updates without the verbosity of cloning or the danger of mutation.
- Draw a clear boundary: keep domain entities as mutable classes with behavior; use records for API contracts and data transfer.
- Avoid mutable reference types in record parameters; prefer immutable collections if you need collections in DTOs.
- Test your records' JSON round-trip behavior to catch serialization surprises early.






Comments
Loading comments…