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 says | Code says | Why it hurts |
|---|---|---|
| settlement | PaymentResult | Two names for one concept; reports and code disagree |
| chargeback | refund | Different lifecycles collapsed into one type |
| holder | user | “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
409with 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.
| Language | Contract tooling |
|---|---|
| PHP | OpenAPI validators, league/openapi-psr7-validator, Pact PHP |
| C# | NSwag, Microsoft.OpenApi, Pact .NET |
| Java | springdoc-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.