Blog/Designing Clean Interface Contracts for Distributed Systems
api-designcsharparchitecturemicroservices

Designing Clean Interface Contracts for Distributed Systems

April 11, 2024·11 min read·by Bishwambhar Sen
A design diagram illustrating explicit boundary inputs and outputs between two service layers with contract validation gates.

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:

  1. Conservative Sending: A service must serialize outgoing data strictly according to the agreed schema, minimizing optional or dynamic structures.
  2. 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 .proto files, 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

Further Reading