Designing Clean Interface Contracts for Distributed Systems

Concept
In distributed architectures, microservices interact across process boundaries. These boundaries are governed by interface contracts. An interface contract is more than just a list of endpoints or a Swagger document; it is a formal agreement that specifies:
- The exact structure of accepted payloads (pre-conditions).
- The exact structure of returned responses (post-conditions).
- The semantic behaviour and error states associated with failures.
- The versioning guarantees of the interface.
Without explicit contracts, systems degrade into a "distributed monolith"—a state where changing a single field in Service A requires coordinated deployments of Services B, C, and D. To avoid this tight coupling, interface contracts must be designed to decouple the public-facing API schema from the internal database models and domain entities.
Postel's Law and API Evolution
Postel's Law (the Robustness Principle) states: "Be conservative in what you do, be liberal in what you accept from others." In distributed systems, this translates to:
- Conservative Sending: A service must serialize outgoing data strictly according to the agreed schema, minimizing optional or dynamic structures.
- Liberal Receiving: A service must ignore unrecognized properties in incoming JSON payloads. This allows upstream services to add new fields to their output without breaking downstream consumers who have not yet upgraded.
Constraints
Designing interface contracts requires managing constraints across serialization protocols, validation latency, and security boundaries:
1. The Serialization Tax
Distributed calls require serializing memory structures into wire formats (JSON, MessagePack, Protobuf) and deserializing them on the receiving end.
- JSON is human-readable and highly flexible, but consumes significant CPU for string parsing and produces larger payloads.
- Protobuf (used by gRPC) is binary, highly compressed, and extremely fast, but requires rigid compilation of
.protofiles, limiting dynamic schema flexibility.
2. Validation Overhead and Defensive Ingestion
Validation must happen at the boundary. Ingesting unvalidated data into the core application layer compromises domain invariants. However, validating complex schemas (especially deep nested structures) incurs a latency penalty. The system must partition validation into:
- Syntactic Validation: Checked at the edge (e.g., matching data types, non-null requirements, string length constraints).
- Semantic Validation: Checked by the core domain (e.g., checking if an account has sufficient balance, requiring database queries).
3. Schema Versioning Paradigms
Microservices must evolve independently. When modifying a contract, developers face a choice:
- Backward-Compatible Changes: Adding optional fields, or removing unused fields after consumer deprecation.
- Breaking Changes: Renaming fields, changing data types, or adding required fields. These require explicit versioning strategies (URL versioning, Header versioning, or content negotiation).
Trade-offs
Sharing Code Assemblies vs. Independent Schemas
In .NET, a common anti-pattern is sharing a library containing request/response DTOs between a service and its clients. While this ensures type safety and reduces duplication, it couples the client's compile-time dependencies to the service's internal assemblies. If the service updates its dependencies (e.g., upgrading a JSON library), the client may experience runtime assembly conflicts. The alternative is Schema-First Design (such as OpenAPI or Protobuf), where the interface contract is described in a language-neutral format, and clients generate their own local types. This decouples technology stacks but introduces code generation build steps.
Rich Domain Models vs. Anemic DTOs
Public DTOs (Data Transfer Objects) must remain "anemic"—simple property containers with no business logic or behavior. Rich domain entities (which encapsulate business rules and invariants) must never be serialized directly. Exposing domain entities directly leaks internal implementation details, making it impossible to refactor the database or internal domain model without breaking the public contract.
Code
The following example demonstrates a robust, decoupled interface contract architecture in C#. It uses a clean interface boundary, separates public DTOs from internal Domain entities, and implements a validation decorator pattern to enforce contract pre-conditions before delegating to the domain logic.
First, we define the public contract (DTOs and the Interface):
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Mpc.Contracts.Billing
{
// The public DTO contract representing the request payload
public record CreateSubscriptionRequest(
string TenantId,
string PlanId,
decimal InitialCharge,
string CurrencyCode,
string PaymentMethodToken);
// The public DTO contract representing the response payload
public record SubscriptionResponse(
string SubscriptionId,
string TenantId,
string Status,
DateTime ExpiresAtUtc);
// The explicit service interface contract
public interface IBillingContract
{
Task<SubscriptionResponse> CreateSubscriptionAsync(
CreateSubscriptionRequest request,
CancellationToken cancellationToken);
}
}
Next, we implement the core domain service, which works strictly with validated models and maps the incoming DTO to its internal domain representation:
using System;
using System.Threading;
using System.Threading.Tasks;
using Mpc.Contracts.Billing;
namespace Mpc.Contracts.Billing.Domain
{
// Internal domain model representing the billing aggregate
internal class SubscriptionAggregate
{
public string Id { get; } = Guid.NewGuid().ToString("N");
public string TenantId { get; }
public string PlanId { get; }
public decimal ActiveBalance { get; private set; }
public string Currency { get; }
public DateTime ExpirationDate { get; }
public SubscriptionAggregate(string tenantId, string planId, decimal initialCharge, string currency)
{
TenantId = tenantId;
PlanId = planId;
ActiveBalance = initialCharge;
Currency = currency;
ExpirationDate = DateTime.UtcNow.AddMonths(1);
}
}
// Core implementation of the contract interface
public class BillingService : IBillingContract
{
public async Task<SubscriptionResponse> CreateSubscriptionAsync(
CreateSubscriptionRequest request,
CancellationToken cancellationToken)
{
// The request is guaranteed to be valid at this point due to the validation decorator
var subscription = new SubscriptionAggregate(
request.TenantId,
request.PlanId,
request.InitialCharge,
request.CurrencyCode);
// In a real system, save to repository and initiate payment gateway call
await Task.Delay(100, cancellationToken); // Simulate DB write
return new SubscriptionResponse(
subscription.Id,
subscription.TenantId,
"Active",
subscription.ExpirationDate);
}
}
}
To enforce contract boundaries, we write a Validation Decorator. This decorator intercepts the contract call, runs validation, and prevents invalid inputs from reaching the core domain:
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using System.Threading.Tasks;
using Mpc.Contracts.Billing;
namespace Mpc.Contracts.Billing.Validation
{
public class BillingContractValidationDecorator : IBillingContract
{
private readonly IBillingContract _inner;
public BillingContractValidationDecorator(IBillingContract inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public Task<SubscriptionResponse> CreateSubscriptionAsync(
CreateSubscriptionRequest request,
CancellationToken cancellationToken)
{
// Execute pre-condition checks
ValidateRequest(request);
// Delegate to core business logic if validation passes
return _inner.CreateSubscriptionAsync(request, cancellationToken);
}
private static void ValidateRequest(CreateSubscriptionRequest request)
{
if (request == null)
{
throw new ArgumentNullException(nameof(request), "Payload cannot be null.");
}
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(request.TenantId))
errors.Add("TenantId is required.");
if (string.IsNullOrWhiteSpace(request.PlanId))
errors.Add("PlanId is required.");
if (request.InitialCharge < 0)
errors.Add("InitialCharge cannot be negative.");
if (string.IsNullOrWhiteSpace(request.CurrencyCode) || request.CurrencyCode.Length != 3)
errors.Add("CurrencyCode must be a valid 3-letter ISO code.");
if (string.IsNullOrWhiteSpace(request.PaymentMethodToken))
errors.Add("PaymentMethodToken is required.");
if (errors.Count > 0)
{
throw new ValidationException(
$"Contract violation detected on ingestion: {string.Join(" | ", errors)}");
}
}
}
}
Decoupling the Edge and the Core
The sequence below highlights how request validations occur at the boundary (via the decorator) before reaching the Core service, preventing execution on invalid payloads:
sequenceDiagram
participant Client as API Client
participant Dec as Validation Decorator
participant Svc as Core Billing Service
participant DB as Database
Client->>Dec: CreateSubscriptionAsync(Request)
Note over Dec: ValidateRequest(Request)
alt Request is Invalid
Dec-->>Client: Throw ValidationException (400 Bad Request)
else Request is Valid
Dec->>Svc: CreateSubscriptionAsync(Request)
Note over Svc: Map to SubscriptionAggregate
Svc->>DB: Save Changes
DB-->>Svc: Ack
Svc-->>Dec: SubscriptionResponse DTO
Dec-->>Client: SubscriptionResponse DTO
end