A Practical Guide to End-to-End Contract-Based Development in .NET 10

Read this article on Medium.

Most bugs in distributed .NET systems don’t come from bad algorithms — they come from drift.

The UI expects one shape of data, the API returns another, and tests quietly fall out of sync. Each layer evolves independently until something breaks in production.

Contract-based development flips that dynamic. Instead of treating DTOs as disposable plumbing, it makes them the source of truth — shared, versioned, and enforced across Blazor SSR, WASM, Minimal APIs, and tests.

In this article, we’ll walk through a practical, end-to-end approach to contract-based development in .NET 10, which demonstrates how development teams define DTOs once, reuse them everywhere, and build systems that evolve safely without constant firefighting.

The Problem: Drift Between Client and Server

In most .NET systems, client and server don’t break all at once… they drift.

A field is renamed in the API.
The UI keeps compiling.
Tests still pass.
Production quietly breaks.

This happens because data contracts are often treated as implementation details, not as first-class citizens. DTOs get duplicated, reshaped, or redefined in each layer — API, UI, and tests — with the assumption that “we’ll keep them in sync.”

In reality, they rarely stay that way.

As systems grow, this drift shows up as:

  • Mapping logic scattered everywhere
  • UI models that don’t quite match API responses
  • Breaking changes discovered late or not at all
  • Fear of touching shared code

The result isn’t just bugs, it’s lost confidence. Developers hesitate to refactor. Features take longer to ship. Small changes carry unexpected risk.

Contract-based development exists to stop this drift. By defining DTOs once and treating them as stable boundaries, teams regain control over how systems evolve… deliberatelypredictably, and safely.

What Contract-Based Development Actually Means

Contract-based development is simple in concept, but powerful in practice:

The DTO is the contract.

Instead of being a temporary shape used to move data between layers, a DTO becomes a deliberate boundary that both the client and the server agree on. Every layer — Blazor SSR, Blazor WASM, Minimal APIs, handlers, and tests — depends on that contract staying stable.

This is different from:

  • Copying DTOs into multiple projects
  • Shared models” with unclear ownership
  • Letting domain models leak across boundaries

In contract-based development:

  • DTOs are defined once
  • Changes are intentional and visible
  • Breaking changes surface at compile time, not in production

The contract doesn’t own business logic. It owns shape and intent.
It answers questions like:

  • What data is allowed to cross this boundary?
  • What does the client expect?
  • What guarantees does the server provide?

Everything else adapts to the contract, not the other way around.

This approach creates a natural forcing function: If a contract changes, every affected layer must acknowledge it. That friction is intentional. It’s how teams prevent silent breakage and accidental drift.

Defining DTOs Once (and Owning Them)

The most important rule of contract-based development is also the simplest:

Define your DTOs once, and give them a clear owner.

In many systems, DTOs are scattered and even duplicated across API projects, UI projects, and test assemblies. Each copy starts identical, then slowly diverges. That’s how drift begins.

Development teams avoid this by creating a dedicated contracts project whose sole responsibility is to define stable data shapes.

A Typical Contracts Structure

src/
├── App.Contracts/
│ ├── Users/
│ │ ├── UserDto.cs
│ │ ├── CreateUserDto.cs
│ └── Orders/
│ └── OrderDto.cs

This project:

  • Has no dependency on infrastructure
  • Has no EF Core references
  • Has no UI logic
  • Contains only DTOs and contract-related concerns

It exists to answer one question:
What data is allowed to cross system boundaries?

Why Ownership Matters

When DTOs live in a sharedintentional location:

  • Changes are explicit and visible
  • Breaking changes fail fast at compile time
  • Teams stop “just adding a field” casually
  • Refactoring becomes safer, not scarier

The contracts project becomes a stability anchor. Everything else — APIsBlazor componentshandlerstests — depends on it, not the other way around.

What Belongs in a DTO (and What Doesn’t)

DTOs should represent shape and intent, not behavior.

They are ideal for:

  • API request and response models
  • UI data binding
  • Serialization boundaries
  • Test fixtures

They should not contain:

  • Business rules
  • Persistence logic
  • Domain invariants

That separation keeps contracts stable even as internal implementations change.

Using the Same DTO Everywhere (Without Regret)

Once DTOs are defined and owned, the real payoff comes from using them end-to-end — unchanged — across the entire stack.

This is where contract-based development stops being a theory and starts saving real time.

Minimal APIs: Contracts at the Boundary

Minimal APIs pair naturally with DTOs because they keep the boundary explicit.

app.MapPost("/users", async (
CreateUserDto dto,
IMediator mediator) =>
{
var result = await mediator.Send(new CreateUserCommand(dto));
return Results.Ok(result);
});

What this gives you:

  • The API surface is self-documenting
  • Model binding is predictable
  • Breaking changes surface immediately
  • No hidden mapping layers

The DTO is the contract — nothing more, nothing less.

Blazor SSR and WASM: Zero Translation Layers

The same DTO flows directly into UI components.

<EditForm Model="_model" OnValidSubmit="Save">
<InputText @bind-Value="_model.Email" />
</EditForm>

@code {
private CreateUserDto _model = new();
}

What this gives you:

  • No “UI models.”
  • No duplicated shapes.
  • No fragile mapping glue.

The contract stays identical, whether the component runs in:

  • SSR
  • WebAssembly
  • Auto mode

Sharing Behavior with Partial Classes

DTOs can remain pure while still gaining shared logic through partial classes.

public partial class UserDto
{
public string DisplayName =>
$"{FirstName} {LastName}";
}

What this gives you:

  • UI-friendly helpers
  • Formatting logic
  • Non-breaking computed values

All without polluting API contracts or introducing dependencies.

Development teams use this sparingly, but when they do, it’s intentional and safe.

Tests: Contract Stability by Default

Because DTOs are shared, tests naturally validate the same shapes used in production.

var dto = new CreateUserDto
{
Email = "[email protected]"
};

var response = await client.PostAsJsonAsync("/users", dto);
response.EnsureSuccessStatusCode();

If a contract breaks:

  • Tests fail
  • Builds fail
  • Teams notice immediately

This is what real stability looks like.

Why This Works Long-Term

Using the same DTO everywhere:

  • Eliminates drift
  • Reduces cognitive load
  • Improves onboarding
  • Makes refactoring safe

You stop asking, “Which version of this model is correct?”
There’s only one.

Source-Generated JSON: Fast, Explicit, and Contract-Safe

Source-generated JSON serialization is one of those features that looks like a free win.

Faster serialization.
Lower allocations.
Compile-time safety.

And when used correctly, it is a win — especially in contract-driven systems.

Why We Care About Source Generation

Source generation is a compile-time technique where tooling analyzes your code and automatically generates additional C# source files that become part of your application… without runtime reflection.

Traditional reflection-based serialization is:

  • Flexible
  • Convenient
  • Opaque

Source generation flips that:

  • Explicit contracts
  • Compile-time validation
  • Predictable behavior

That predictability is what makes it attractive for DTO-heavy systems.

Defining the Serialization Boundary

Source generation starts with declaring exactly which DTOs are part of your public contract.

[JsonSerializable(typeof(CreateUserDto))]
[JsonSerializable(typeof(UserDto))]
public partial class ApiJsonContext : JsonSerializerContext
{
}

This does two important things:

  • Locks down what gets serialized
  • Prevents accidental surface-area growth

If a DTO isn’t listed, it doesn’t magically start flowing through your API.

That’s intentional.

Wiring It into Minimal APIs

Minimal APIs make it easy to opt into source-generated serialization.

This code makes HttpClient use source-generated JSON converters first, which improves performancesafety, and compile-time validation when sending/receiving DTOs.

builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(
0, ApiJsonContext.Default);
});
  • Uses source-generated JSON serialization instead of reflection-based serialization.
  • Ensures predictable, fast, and type-safe serialization for your DTOs.
  • Overrides default serialization behavior for your HttpClient requests/responses.

From this point forward:

  • All matching DTOs use generated serializers
  • Reflection is avoided
  • Runtime surprises disappear

If you break a contract, the compiler tells you.

Why This Improves Stability

Source generation forces discipline:

  • DTO shape changes are explicit
  • Serialization behavior is visible
  • Performance characteristics are predictable

You don’t accidentally serialize:

  • Private fields
  • Navigation properties
  • Lazy-loaded graphs

Only what you declared and nothing more.

Where We Draw the Line

Not everything needs source generation.

Development teams usually:

  • Apply it at API boundaries to enforce contract stability.
  • Avoid it in UI-only models where flexibility matters more than strict serialization.
  • Skip it for highly dynamic payloads to reduce unnecessary complexity.

Why?

  • Configuration overhead: Setting up source generation requires extra project setup and maintenance.
  • Tooling friction: Some editors and debugging tools don’t handle generated serializers as seamlessly.
  • Reduced flexibility: Dynamic or evolving payloads can’t leverage source generation without extra work.

The rule is simple:

If it’s a contract, generate it. If it’s internal, keep it flexible.

Testing the Generated Contract

Because serializers are generated at compile timetests become simpler and more trustworthy.

var json = JsonSerializer.Serialize(dto, ApiJsonContext.Default.UserDto);
var roundTrip = JsonSerializer.Deserialize<UserDto>(json, ApiJsonContext.Default.UserDto);

roundTrip.Should().BeEquivalentTo(dto);

This guarantees:

  • Symmetry: Serialization and deserialization behave predictably, ensuring no accidental data loss.
  • Compatibility: Changes to the DTO surface are caught immediately, preventing runtime mismatches.
  • Long-term stability: Contracts remain reliable over time, reducing regression risk and build-time surprises.

No reflection magic.
No surprises after deployment.

Why This Ages Well

Source-generated JSON doesn’t just improve performance — it reinforces stability across the system. Here’s why senior developers love it:

  • Shared DTOs: Because DTOs are defined once and used everywhere, source generation ensures every boundary respects the same shape, reducing the risk of drift.
  • Minimal APIs: At API boundaries, source-generated serialization makes intent explicit, avoiding hidden surprises and keeping endpoints predictable.
  • Vertical slice architectures: Each feature slice can depend on its own clearly defined contracts, letting handlers, endpoints, and UI components share the same DTOs safely.
  • AOT scenarios: Ahead-of-Time compilation benefits from predictable serialization patterns, making startup and runtime behavior faster and more reliable.

It trades flexibility for clarity — and that’s a trade developers are happy to make at system boundaries.

Testing DTO Boundaries to Guarantee Stability

Defining and sharing DTOs is only half the battle. The other half is testing them like first-class citizens. When contracts are enforced at compile time and verified through tests, teams gain confidence — they can refactorship features, and onboard new developers without fear.

Why Test Contracts

Even with source-generated serialization, mistakes can slip through:

  • Fields may be added or removed accidentally
  • Mapping mistakes could break APIs
  • Round-trip serialization may differ between client and server

By testing DTOs, you catch these issues before deployment, not after users notice.

Round-Trip Serialization Tests

A simple yet powerful test ensures symmetry:

var dto = new CreateUserDto
{
Email = "[email protected]"
};

var json = JsonSerializer.Serialize(dto, ApiJsonContext.Default.CreateUserDto);
var roundTrip = JsonSerializer.Deserialize<CreateUserDto>(json, ApiJsonContext.Default.CreateUserDto);

roundTrip.Should().BeEquivalentTo(dto);

This guarantees:

  • Symmetry: Serialized and deserialized objects match exactly
  • Compatibility: Changes to DTOs surface immediately
  • Long-term stability: Contracts remain reliable over time

Integration and API Tests

DTOs can also be validated through Minimal API or Blazor integration tests:

var response = await client.PostAsJsonAsync("/users", dto);
response.EnsureSuccessStatusCode();

If a contract drifts, the test fails. Developers know immediately where the problem lies.

Why This Matters

Development teams understand that the real value of contract-based development isn’t just code organization — it’s confidence at scale. With proper DTO testing:

  • Refactors are safe
  • Cross-team collaboration is easier
  • Production bugs decrease

Testing DTO boundaries turns the contract from a theoretical agreement into a guaranteed, verifiable safety net.

How This Supports Blazor Auto Mode

Blazor Auto Mode is the new hybrid standard in .NET 10, combining SSR (Server-Side Rendering) and WASM (WebAssembly) seamlessly. Contract-based development makes this transition effortless.

Why DTOs Matter in Auto Mode

In Auto Mode:

  • SSR renders the page server-side for fast first paint
  • WASM runtime downloads for full interactivity
  • State must stay consistent across both environments

DTOs act as the single source of truth, ensuring the data shape is identical for SSR and WASM. Without stable contracts, the UI risks mismatches, hydration errors, or subtle runtime bugs.

Example: Shared DTO Between SSR and WASM

<CascadingValue Value="appState">
<UserDetail Id="@userId" />
</CascadingValue>

@code {
[Parameter] public Guid userId { get; set; }
private UserDto? user;

protected override async Task OnInitializedAsync()
{
user = await Http.GetFromJsonAsync<UserDto>($"/api/users/{userId}");
}
}

Here, the same UserDto flows from Minimal API → MediatR Handler → EF Core → SSR page → WASM page.

No mapping.
No drift.
No surprises.

Why This Works End-to-End

  • SSR first paint: Users see content immediately
  • Hydration consistency: WASM picks up the same contract, preventing UI glitches
  • Real-time updates: SignalR or other streaming data can continue to use the same DTOs
  • Testable boundaries: Both server and client tests validate the same data shape

The Developer Advantage

By combining DTO-driven contracts with Blazor Auto Mode:

  • You avoid duplicate UI models
  • You reduce hydration bugs
  • You maintain predictable, testable state across SSR and WASM
  • You make refactoring safe even in large, complex applications

Contracts aren’t just for APIs anymore.
They’re the backbone of 
modern, full-stack .NET 10 applications.

End-to-End Example: From Page to Database

To see contract-based development in action, let’s walk through a full feature: creating a user, from the Blazor page to the database and back, including real-time updates.

Step 1: The DTO (Contract)

public record CreateUserDto(string Email, string FirstName, string LastName);
public record UserDto(Guid Id, string Email, string FirstName, string LastName);
  • CreateUserDto defines the input for creating a user
  • UserDto defines the output that flows through API, Blazor, and tests

Step 2: Minimal API Endpoint

app.MapPost("/users", async (
CreateUserDto dto,
IMediator mediator) =>
{
var result = await mediator.Send(new CreateUserCommand(dto));
return Results.Ok(result);
});
  • The endpoint is thin and readable
  • No business logic here. It delegates to the handler

Step 3: MediatR Command and Handler

public record CreateUserCommand(CreateUserDto Dto) : IRequest<UserDto>;

public class CreateUserHandler : IRequestHandler<CreateUserCommand, UserDto>
{
private readonly AppDbContext _db;

public CreateUserHandler(AppDbContext db) => _db = db;

public async Task<UserDto> Handle(CreateUserCommand command, CancellationToken ct)
{
var user = new User(command.Dto.Email, command.Dto.FirstName, command.Dto.LastName);
await _db.Users.AddAsync(user, ct);
await _db.SaveChangesAsync(ct);
return user.ToDto();
}
}
  • The handler owns the feature
  • Validationmapping, and orchestration live here
  • No “fat services” hiding logic elsewhere

Step 4: Blazor Component (SSR/WASM)

<EditForm Model="_model" OnValidSubmit="Save">
<InputText @bind-Value="_model.Email" />
<InputText @bind-Value="_model.FirstName" />
<InputText @bind-Value="_model.LastName" />
<button type="submit">Create User</button>
</EditForm>

@code {
private CreateUserDto _model = new();
[Inject] HttpClient Http { get; set; } = default!;

private async Task Save()
{
var user = await Http.PostAsJsonAsync("/users", _model);
}
}
  • The same DTO flows into the UI
  • No separate view model needed
  • Works in SSR, WASM, and Auto Mode

Step 5: Real-Time Updates (SignalR)

await _hubContext.Clients.All.SendAsync("UserCreated", user);
  • The same UserDto can be pushed to clients
  • Clients subscribe without worrying about shape mismatches

Step 6: Testing the Full Flow

var dto = new CreateUserDto("[email protected]", "John", "Doe");
var response = await client.PostAsJsonAsync("/users", dto);
response.EnsureSuccessStatusCode();

var created = await response.Content.ReadFromJsonAsync<UserDto>();
created.Should().NotBeNull();
created.Email.Should().Be(dto.Email);
  • Test verifies contract symmetry
  • Ensures API, database, and UI all agree

Why This Works

  • Single source of truth: DTOs define boundaries, not behavior
  • Thin endpoints: Minimal APIs delegate, handlers own features
  • Predictable full-stack flow: Blazor SSR/WASM, API, SignalR, and tests all align
  • Testable and maintainable: Refactoring is safe, onboarding is easy

This is the practical payoff of contract-based development in modern .NET 10 systems.

Contracts Are the Backbone of Modern .NET

Across Minimal APIsBlazor SSR/WASMMediatRDTOssource-generated JSON, and even SignalR, a clear pattern emerges: stability beats novelty. Developers don’t chase every shiny feature, they adopt tools that make systems easier to reason about, easier to test, and easier to evolve over time.

Contract-based development enforces a single source of truthDTOs define the boundarieshandlers own the features, and endpoints remain thin and predictable. The result is a full-stack system that scales, reduces cognitive overhead, and survives refactors without breaking a sweat.

By defining contracts once, using them everywhere, generating serializers selectively, and testing boundaries thoroughly, teams gain confidence at scale.

The takeaway: Build your system around explicit, testable contracts. The code that survives isn’t the flashiest — it’s the most deliberate.

Happy coding!