OTAKU / Contracts

Track U — Universal Language & Contracts

Contracts and language

Two systems integrate at a contract, not at a class. The contract is the part you cannot change casually, and the language inside it is the part your business expert must recognize.

Ubiquitous language

If the expert says “settlement”, the code does not sayPaymentResult. Names are not cosmetic: a wrong name forces a translation layer in someone’s head, and that layer is where requirements get lost.

Business saysCode saysWhy it hurts
settlementPaymentResultTwo names for one concept; reports and code disagree
chargebackrefundDifferent lifecycles collapsed into one type
holderuser“User” leaks a technical actor into a financial rule

The test is mechanical: read a use case aloud to the expert. When they interrupt to correct a word, the code is wrong.

Design first

The contract is written before the implementation. It is the artifact two teams can disagree about cheaply, and the one that survives a rewrite.

openapi: 3.1.0
info:
  title: Accounts
  version: 1.4.0
paths:
  /accounts/{id}/withdrawals:
    post:
      operationId: withdraw
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, currency]
              properties:
                amount: { type: integer, minimum: 1 }
                currency: { type: string, enum: [BRL, USD] }
      responses:
        "201":
          description: Withdrawal accepted
        "409":
          description: Insufficient funds
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Problem" }

Note what the contract does not contain: class names, database columns, framework annotations. It describes resources, verbs and failure modes — the same vocabulary the business uses.

The contract inside the code

namespace App\Domain\Account;

final class Withdrawal
{
    private function __construct(
        private AccountId $account,
        private Money $amount,
    ) {}

    public static function fromRequest(AccountId $account, Money $amount): self
    {
        if ($amount->isNegative()) {
            throw new InvalidWithdrawalAmount($amount);
        }

        return new self($account, $amount);
    }

    public function account(): AccountId { return $this->account; }
    public function amount(): Money { return $this->amount; }
}
namespace App.Domain.Account;

public sealed record Withdrawal(AccountId Account, Money Amount)
{
    public static Withdrawal FromRequest(AccountId account, Money amount)
    {
        if (amount.IsNegative)
            throw new InvalidWithdrawalAmount(amount);

        return new Withdrawal(account, amount);
    }
}

The request object is not a DTO bag. It is the smallest type that can represent a valid withdrawal — and the only place where “amount must be positive” is allowed to live.

Compatibility rules

  • Additive only. New optional fields and new endpoints are safe; renaming and removal are breaking.
  • Never reuse a field name with a different meaning. That is a silent breaking change.
  • Version the contract, not the class. A semantic version on the API, not on an internal namespace.
  • Deprecate before deleting. Announce, warn in responses, then remove — in that order.
  • Error shape is part of the contract. A 409 with an undocumented body is a broken promise.

Test the promise, not the implementation

Contract tests validate that the implementation still honours the document: schema validation of responses, consumer-driven tests (Pact), and a CI step that diffs the published spec against the previous version and fails on breaking changes.

LanguageContract tooling
PHPOpenAPI validators, league/openapi-psr7-validator, Pact PHP
C#NSwag, Microsoft.OpenApi, Pact .NET
Javaspringdoc-openapi, Pact JVM
C++spec validation in integration tests, Pact via a binding

Bypassing is the default failure

Contracts are usually not broken by bad design — they are bypassed by convenience: a service calling another’s database, a client reading an internal endpoint, a “temporary” field added straight in production. Each bypass is a second source of truth, and two sources of truth never stay equal for long.

The countermeasure is social before technical: the contract must be the only door, and the door must be cheaper to use than the wall.