Leaders Logo

Contracts that Withstand the Test of Time: Canonical Model and System Evolution in .NET

Introduction

In the context of enterprise software development, building durable systems invariably involves dealing with the evolution of public contracts, whether interfaces, APIs, or integration mechanisms. In the .NET environment, ensuring that contracts withstand the test of time while maintaining compatibility and coherence with evolving business requirements is a critical challenge. This article explores canonical approaches to contract modeling, presents strategies for safe evolution, and practical implementation examples in C#.

Software Contract Fundamentals

Contract Definition

In software engineering, contracts are formal agreements between parties regarding communication rules, transmitted data, and responsibilities involved (MEYER, 1992). In the specific case of APIs and microservices, the contract is often expressed through DTOs (Data Transfer Objects), OpenAPI, Protobuf, or other message-based mechanisms.

The Role of the Canonical Contract

The concept of a canonical contract defines a single, consistent standard for data exchanged between domains or systems. The canonical model avoids redundancies, minimizes data transformation, and facilitates maintenance over time (HOHPE; WOOLF, 2003). In the .NET universe, using cohesive and versioned DTOs is a common strategy.

SVG Image of the Article

Contracts vs. Implementation

It is important to distinguish contract from implementation. Contracts change only when there is a business reason; implementations may evolve frequently without affecting contracts. By separating public DTOs from internal models, we ensure isolation, reducing coupling and the risk of breaking external clients (FOWLER, 2002).

Canonical Contract Modeling in .NET

Modeling Strategies

A recurring strategy for canonical modeling consists in using immutable DTO classes, separated from the domain. These objects are the unique representatives of the public contract and can incorporate explicit versioning mechanisms.

namespace MyApi.Contracts.V1;

public sealed record class CustomerDto
{
    public required Guid Id { get; init; }
    public required string Name { get; init; }
    public required string TaxId { get; init; }
    public required AddressDto Address { get; init; }
}

public sealed record class AddressDto
{
    public required string Street { get; init; }
    public required string City { get; init; }
    public required string State { get; init; }
    public required string ZipCode { get; init; }
}

Here, the canonical form of the contract is standardized, adopted throughout the article: immutable record class with init and required properties. An important clarification: in a record class, explicitly declared properties do not become read-only automatically; it is the init modifier that ensures immutability after construction (only a positional record generates init accessors implicitly). The required ensures that the required fields are set during initialization, while the structural equality of the record favors contract comparisons and testing.

Struct or Class for DTOs?

A frequent decision when modeling contracts in .NET is choosing between struct and class. The fundamental difference is semantic: struct is a value type, copied by value at each assignment or parameter passing; class is a reference type, where variables share the same instance in the managed heap. This distinction affects identity, equality, allocation, and null behavior.

  • Allocation and copying: structs typically live on the stack or embedded within the containing object, without heap allocation; classes are allocated on the heap and collected by the GC. However, large structs make copying expensive.
  • Equality: structs compare by value (field by field); classes compare by reference, except when using record, which generates structural equality.
  • Nullability: a class can be null, representing absence of data; a non-nullable struct is never null, making it hard to express optional fields in a contract.
  • Boxing: treating a struct as an object or interface causes boxing, generating allocation and cost exactly where efficiency was sought.

For DTOs, the recommendation is to use class, preferably as an immutable record class. DTOs usually have several fields, are transferred through serialization (System.Text.Json), accept optional and nullable fields, and circulate through various layers. In this scenario, the reference type avoids costly copies, naturally integrates with serializers, and allows for expressing absence of value via null. The record also provides structural equality and the with method to create modified versions without mutation.

// Recommended: immutable record class for the public contract
public sealed record class CustomerDto
{
    public required Guid Id { get; init; }
    public required string Name { get; init; }
    public string? Email { get; init; }
    public required AddressDto Address { get; init; }
}

// To avoid: struct for a DTO with many fields and optional fields
public struct CustomerStructDto
{
    public Guid Id { get; init; }
    public string Name { get; init; }
    public string? Email { get; init; } // optionality becomes ambiguous in value type
}

The use of struct (or record struct in .NET 10) only makes sense for small immutable value objects, with few fields and strong value semantics, such as Money, Coordinate, or a typed identifier. For the body of a public contract, however, the predictability of serialization and clarity of nullability make the record class the most durable and safe choice.

// Legitimate value type use case: small, immutable typed identifier
public readonly record struct CustomerId(Guid Value);

Contract Naming and Versioning

A recurring question is where to place the version: in the class name (CustomerV2Dto) or in the namespace and route? The most durable practice is to keep the version in the namespace and in the transport (URL segment or media type), preserving the stable type name, CustomerDto, across all versions. This way, client code and mappings remain readable, and the version is concentrated in a single axis (the namespace), avoiding redundant repetition of V2 in both the namespace and the type name.

namespace MyApi.Contracts.V2;

public sealed record class CustomerDto
{
    public required Guid Id { get; init; }
    public required string Name { get; init; }
    public string? Email { get; init; }
    public required string DocumentType { get; init; }
    public required string DocumentNumber { get; init; }
    public required AddressDto Address { get; init; }
}

Separation by namespace allows for non-breaking evolution: old clients continue consuming Contracts.V1, while new clients adopt Contracts.V2. When it is necessary to reference two versions in the same codebase, typically in transformation or migration routines, disambiguation should be done with namespace aliases, not by renaming the type:

using V1 = MyApi.Contracts.V1;
using V2 = MyApi.Contracts.V2;

// V1.CustomerDto and V2.CustomerDto coexist without ambiguity,
// preserving stable names in both versions.

Embedding the version in the type name (CustomerV2Dto) should therefore be avoided for DTOs. The legitimate exception is immutable event and message contracts, where past versions are permanent facts that coexist by definition; in this case, versioned names (CustomerCreatedV2Event) better communicate the historical nature of the contract.

Mapping Between Contract and Internal Domain

The mapping between DTOs (public contracts) and domain models is essential to maintain internal cohesion. Tools like AutoMapper assist with this process; however, manual mapping is recommended for critical contracts to ensure fine control and auditing.

using MyApi.Contracts.V2;

public static class CustomerMapper
{
    public static Customer MapToDomain(CustomerDto dto)
    {
        return new Customer(
            id: dto.Id,
            name: dto.Name,
            email: dto.Email,
            type: CustomerType.FromString(dto.DocumentType),
            document: dto.DocumentNumber,
            address: MapAddress(dto.Address)
        );
    }

    private static Address MapAddress(AddressDto dto)
    {
        return new Address(
            street: dto.Street,
            city: dto.City,
            state: dto.State,
            zipCode: dto.ZipCode,
            country: dto.Country
        );
    }
}

Contract Versioning

Versioning Approaches

The main approaches are:

  • URI Versioning: Different URLs for versions (e.g., /api/v1/customers vs /api/v2/customers).
  • Header Versioning: HTTP Headers, such as Accept: application/vnd.mycompany.v2+json.
  • Content Versioning: The payload itself carries the version.

In .NET, there are middlewares and libraries to facilitate implementation, such as Microsoft.AspNetCore.Mvc.Versioning.

builder.Services.AddApiVersioning(options =>
{
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.ReportApiVersions = true;
    options.ApiVersionReader = new UrlSegmentApiVersionReader();
}).AddApiExplorer();

Ensuring Backward Compatibility

Backward compatibility requires automated tests and continuous schema validations. A contract can only be considered to withstand the test of time if it never breaks existing clients. Recommended practices include:

  • Do not remove required fields.
  • Avoid semantic changes to field types.
  • Only add optional fields.
namespace MyApi.Contracts.V1
{
    // TaxId remains required
    public sealed record class CustomerDto { public required string TaxId { get; init; } }
}

namespace MyApi.Contracts.V2
{
    // Adds optional Email, additive and backward compatible change
    public sealed record class CustomerDto { public string? Email { get; init; } }
}

This pattern allows older contracts to remain valid.

Testing, Validation, and Automation

Contract tests

Contract tests ensure that unintended changes are not propagated. Tools like Pact.NET enable consumer-driven contracts: the consumer defines expectations, and the provider continuously validates them.

[Fact]
public async Task EnsureCustomerContract()
{
    var customer = await _httpClient.GetFromJsonAsync<CustomerDto>(
        "/api/v1/customers/123", TestContext.Current.CancellationToken);

    Assert.NotNull(customer);
    Assert.False(string.IsNullOrEmpty(customer.TaxId));
    Assert.NotEqual(Guid.Empty, customer.Id);
}

Additionally, using JSON Schema and Protobuf Schema Registry makes it possible to automatically validate contracts for REST APIs and event-driven systems.

Automatic Schema Validation

Integration with CI/CD pipelines can ensure that any DTO changes are reflected and validated before going to production.

public class ApiSchemaTests
{
    [Theory]
    [InlineData("Schemas/v1/customer.schema.json")]
    [InlineData("Schemas/v2/customer.schema.json")]
    public void Should_Match_Contract(string schemaFilePath)
    {
        var schema = JSchema.Parse(File.ReadAllText(schemaFilePath));
        var customerDto = GetExampleDto(Path.GetFileName(schemaFilePath));
        var json = JsonConvert.SerializeObject(customerDto);

        Assert.True(JObject.Parse(json).IsValid(schema));
    }

    private object GetExampleDto(string schemaName)
    {
        // returns a sample DTO filled according to the version
    }
}

Automating Contract Transformation

With multiple versions, it is useful to automate contract transformation, enhancing interoperability and progressively migrating legacy clients.

using V1 = MyApi.Contracts.V1;
using V2 = MyApi.Contracts.V2;

public static class ContractTransformer
{
    public static V2.CustomerDto Upgrade(V1.CustomerDto oldDto) =>
        new()
        {
            Id = oldDto.Id,
            Name = oldDto.Name,
            Email = null,
            DocumentType = "CPF",
            DocumentNumber = oldDto.TaxId,
            Address = AddressTransformer.Upgrade(oldDto.Address)
        };
}

System and Contract Evolution Over Time

Standards for Safe Evolution

Among classic patterns, Backward Compatible Expansion stands out, where only non-destructive additions are allowed (NEWMAN, 2021). In .NET, changes are accompanied by warnings (deprecated), guiding gradual migration.

[Obsolete("Use 'DocumentNumber' and 'DocumentType' in Contracts.V2.CustomerDto", false)]
public string TaxId { get; init; }

Additionally, practices such as Feature Flags, Toggle Routers, and API Gateways allow routing/directing traffic to specific versions, controlling the exposure of evolutionary features (FOWLER, 2002).

Data Migration for New Contracts

Contract updates often require data migration. Example: splitting CPF/CNPJ into separate fields. Entity Framework Core facilitates migrations scripts and allows progressive transformations, minimizing downtime:

// Migration to add separate columns
migrationBuilder.AddColumn<string>(
    name: "DocumentType",
    table: "Customers",
    nullable: true);

migrationBuilder.AddColumn<string>(
    name: "DocumentNumber",
    table: "Customers",
    nullable: true);

// Auxiliary job to migrate existing data
public async Task MigrateTaxIdAsync()
{
    using var db = new CustomerDbContext();
    var entries = await db.Customers.ToListAsync();
    foreach (var customer in entries)
    {
        if (Regex.IsMatch(customer.TaxId, @"^\d{11}$"))
        {
            customer.DocumentType = "CPF";
        }
        else if (Regex.IsMatch(customer.TaxId, @"^\d{14}$"))
        {
            customer.DocumentType = "CNPJ";
        }
        customer.DocumentNumber = customer.TaxId;
    }
    await db.SaveChangesAsync();
}
GIF Image of the Article

Strategies for Contract Coexistence

Coexisting with multiple contracts implies infrastructural decoupling: Layered APIs, Independent Schema Deployments, and controlled dispatch mechanisms in API Gateways are necessary in large-scale ecosystems (NEWMAN, 2021).

Documentation and Official Records

Registering contracts in Schema Registries (OpenAPI, Protobuf Descriptors) and controlling versions via Git are practices that increase traceability and reproducibility, protecting the integrity of organizational knowledge.

// OpenAPI contract registration
services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
    c.SwaggerDoc("v2", new OpenApiInfo { Title = "My API", Version = "v2" });
    c.DocInclusionPredicate((version, apiDescription) =>
    {
        var versions = apiDescription.CustomAttributes()
            .OfType<ApiVersionAttribute>().SelectMany(attr => attr.Versions);
        return versions.Any(v => $"v{v}" == version);
    });
});

Architectural Patterns for Durable Contracts

API Gateway and Facade Layer

API Gateway centralizes versioning, logging, security, and routing policies, enabling multiple consistent public contracts over time (NEWMAN, 2021). Facades provide adaptation between old models and new contracts.

Event Sourcing and CDC (Change Data Capture)

In event-driven systems, contract evolution follows the versioning of published messages (Events). With Event Sourcing, historical contracts are reconstructed and replayed, ensuring full auditability (VERNON, 2013).

[ProtoContract]
public sealed record class CustomerCreatedV1Event
{
    [ProtoMember(1)] public Guid Id { get; init; }
    [ProtoMember(2)] public string Name { get; init; }
    [ProtoMember(3)] public string TaxId { get; init; }
}

// When evolving to V2:
[ProtoContract]
public sealed record class CustomerCreatedV2Event
{
    [ProtoMember(1)] public Guid Id { get; init; }
    [ProtoMember(2)] public string Name { get; init; }
    [ProtoMember(3)] public string DocumentType { get; init; }
    [ProtoMember(4)] public string DocumentNumber { get; init; }
}

Microservices, Contract-As-Code, and backwards-only verification

In microservices environments, contracts endure over time through the Contract-as-Code process, change control via pull requests, and backward only verification jobs, rejecting unauthorized breaking changes (NEWMAN, 2021).

Challenges, Antipatterns and Lessons Learned

Common Antipatterns

Among the main antipatterns observed:

  • Silent contract breaking by changing return values without versioning.
  • Adoption of generic fields (object, dynamic) to circumvent evolution.
  • Deep and non-auditable transformations between internal models and public contracts.
  • Merging versions, creating inconsistent or ambiguous contracts.

Conclusion

The durability of contracts in .NET systems depends on a combination of architectural rigor, transparent versioning, automated testing, and continuous governance. By separating public contracts from internal implementations, adopting a cohesive canonical model, allowing only additive changes, and validating each modification in continuous integration pipelines, teams drastically reduce the risk of breaking existing clients.

The controlled coexistence of multiple versions, coupled with strategies for gradual deprecation and progressive data migration, transforms the evolution of systems into a predictable and auditable process. Contracts that stand the test of time are not a matter of chance, but rather of engineering discipline: they preserve interoperability, protect investment in integrations, and support the longevity of distributed architectures.

References

  • NEWMAN, Sam. Building microservices: designing fine-grained systems. 2nd ed. Sebastopol: O'Reilly Media, 2021. reference.Description
  • MEYER, Bertrand. Applying "design by contract". Computer, New York, v. 25, n. 10, pp. 40-51, 1992. reference.Description
  • HOHPE, Gregor; WOOLF, Bobby. Enterprise integration patterns: designing, building, and deploying messaging solutions. Boston: Addison-Wesley, 2003. reference.Description
  • FOWLER, Martin. Patterns of enterprise application architecture. Boston: Addison-Wesley, 2002. reference.Description
  • VERNON, Vaughn. Implementing Domain-Driven Design. Boston: Addison-Wesley, 2013. reference.Description
About the author