One of the biggest differences between working on a single backend and working across multiple services is that a small change is no longer local.
I have worked on platforms where document, question, entity, workflow, notification, and user-related services communicate with each other. In that kind of system, changing one API can break another service without producing an obvious error where the change was made.
The dangerous changes are often not the dramatic ones.
They look harmless:
make a field required
rename a status
change null to []
wrap a response in { data: ... }
add tenant context
change an internal authentication assumptionThe service being edited still passes its own tests.
Another service quietly stops understanding it.
An API is more than its endpoint
It is easy to think of an API contract as this:
GET /entities/{id}But the real contract is much larger.
It includes:
HTTP method
path
request fields
response fields
types
nullability
defaults
enums
error shapes
status codes
authentication assumptions
tenant context
ordering
pagination
side effectsIf another service depends on any of those details, changing them is a contract change even if the URL stays exactly the same.
That was an important shift in how I started reviewing backend changes.
Required fields are not a small change
Suppose an entity service originally accepts:
{
"name": "Contract A"
}Later the service becomes multi-tenant and the new model requires:
{
"name": "Contract A",
"tenant_id": "tenant-123"
}Inside the entity service, this may look like a clean improvement.
But what happens to an older document service that still creates entities using the old payload?
It now gets a validation error.
The entity service is technically correct. The document service is also using the contract it was originally given.
The system is broken because the migration path was missing.
When adding required data, I now ask:
- Can the server derive it from auth context?
- Can the field be optional during a migration window?
- Which callers need to be updated first?
- Can old and new clients coexist temporarily?
This is especially important for tenant identifiers, user identifiers, and workflow state because they tend to propagate through several services.
Defaults are part of the contract too
A subtle example is changing this:
{
"relations": null
}into this:
{
"relations": []
}That looks like an improvement.
But downstream code may distinguish between:
null -> not loaded / unknown
[] -> loaded and emptyThe same problem appears with booleans, timestamps, missing keys, empty strings, and default enum values.
I learned not to dismiss these as serialization details. If consumers branch on the value, it is part of the behavioral contract.
Enums are more dangerous than they look
Workflow systems make this very visible.
Imagine a consumer understands:
PENDING
APPROVED
REJECTEDThen the workflow service adds:
UNDER_REVIEWFrom the workflow service perspective this is backward-compatible: no existing value changed.
But a consumer may contain logic like:
if status == "PENDING":
...
elif status == "APPROVED":
...
else:
reject_as_invalid()The new enum value is valid for the producer and invalid for the consumer.
This is why I prefer consumers that tolerate unknown future values when the business rules allow it, and why enum additions still deserve cross-service review.
Authentication changes are contract changes
Some of the most frustrating integration bugs I have seen were not payload problems at all.
They were identity problems.
A service may be called in two different ways:
user -> service
service -> serviceThose calls may carry different identity information.
A user-facing request might have a bearer token containing user and tenant claims. An internal call may use a service credential and pass user or tenant context separately.
If one backend suddenly assumes every request has the same user token structure, internal callers can start failing even though the endpoint and JSON body did not change.
For me, this made authentication part of the API contract, not just middleware sitting in front of it.
I now want an endpoint contract to answer:
Who is allowed to call this?
How is caller identity represented?
Where does tenant context come from?
Can an internal service act on behalf of a user?
What happens when that context is missing?Multi-tenancy multiplies contract risk
Adding multi-tenancy to an existing backend is a good example of why service boundaries matter.
It is not enough to add:
tenant_id UUID NOT NULLto a few tables.
The tenant context has to survive the whole request path:
incoming request
↓
authentication
↓
service logic
↓
internal API calls
↓
queries
↓
background jobs
↓
notifications / eventsIf one internal call forgets the tenant, the failure can appear in a completely different service.
Worse, a missing tenant filter can become a data-isolation problem rather than a normal application bug.
That is why I treat tenant propagation as a first-class cross-service contract.
Shared models help, but they can also couple services
A tempting solution is to put every request and response model into one shared package.
That prevents some drift, but it introduces another type of coupling.
If every service must upgrade the same shared package at the same time, independent deployment becomes harder.
I prefer sharing only genuinely stable primitives where it makes sense, while keeping service contracts explicit and versioned at the boundary.
The goal is not to eliminate duplication at any cost.
The goal is to make compatibility visible.
The checklist I use before changing an API
Before changing a backend contract, I try to answer these questions:
1. Who calls this endpoint today?
2. Is the change additive or breaking?
3. Are defaults or nullability changing?
4. Are enum values changing?
5. Does auth or tenant behavior change?
6. Can old and new consumers coexist?
7. Does a database migration have to happen before code deployment?
8. Can the producer support both shapes temporarily?
9. What telemetry will tell me a consumer is still using the old contract?This does not require heavyweight architecture governance.
Even a short checklist catches many failures before they become production debugging sessions.
Compatibility windows are underrated
One of the safest patterns is to avoid changing producer and consumer atomically when you do not need to.
For example:
Step 1: producer accepts old + new request
Step 2: deploy updated consumers
Step 3: verify old contract usage disappears
Step 4: remove legacy pathThe same idea works for response fields:
add new field
↓
move consumers to it
↓
stop depending on old field
↓
remove old field laterThis costs a little temporary complexity but makes deployment much less fragile.
Contract tests are more valuable than duplicated unit tests
A service can have excellent unit tests and still break every caller.
The missing test is often at the boundary.
I find a few contract-oriented checks more valuable than repeating internal implementation tests across repositories.
For example:
Can Service A still create the resource expected by Service B?
Does the response shape still contain what the consumer reads?
Does tenant context survive the internal call?
Does the same error condition still return the expected status code?These tests focus on what another service can observe.
That is the part that actually forms the contract.
What I learned
The longer I work with multiple backend services, the less I believe in "small API changes."
A change can be small in code and large in dependency impact.
The patterns I now try to follow are:
- treat defaults and nullability as contract behavior
- treat authentication and tenant propagation as part of the API
- assume enum additions can affect consumers
- prefer additive changes before removals
- give services a compatibility window
- know the callers before changing the producer
- test observable boundaries, not only internal functions
- use telemetry to find old consumers before deleting compatibility code
The important question is no longer:
Does this service still work?
It is:
Does the system that depends on this service still understand it?
That is a much better review question for backend changes in a multi-service environment.