Stop Rewriting APIs: Architect for Unchanging Backend Logic
Most API rewrites begin with a reasonable request: a new mobile app, a partner integration, a redesigned checkout, or a faster reporting screen. The mistake is treating that new request as proof that the backend’s business logic must be rebuilt.
An API is a delivery mechanism. Your rules for pricing, permissions, inventory, billing, and state transitions are the business. When those rules are tangled into controllers, request formats, ORM queries, and response serialization, every new client becomes an excuse to rewrite code that should have stayed stable.
The goal is not to freeze your system forever. It is to architect the backend so that change happens at the edges while the core logic remains understandable, testable, and deliberately boring.
Separate the changing API from the stable business rules
A useful boundary is simple: HTTP concerns belong outside the application core. Controllers should translate a request into an input the application understands, call a use case, and translate the result into a response. They should not decide whether an order can be cancelled or calculate a discount hidden inside a JSON payload.
In PHP, that can look like this:
final class CancelOrderController
{
public function __invoke(
ServerRequestInterface $request,
CancelOrder $cancelOrder
): ResponseInterface {
$orderId = (string) $request->getAttribute('orderId');
$actorId = (string) $request->getAttribute('actorId');
$result = $cancelOrder->handle(
new CancelOrderCommand($orderId, $actorId)
);
return new JsonResponse([
'orderId' => $result->orderId,
'status' => $result->status,
]);
}
}
The controller knows HTTP. The CancelOrder use case knows the operation. The domain rules can then be reused by a REST endpoint, a command-line job, a queue consumer, or a future GraphQL resolver without copying the cancellation policy.
This distinction also makes API versioning less frightening. A versioned endpoint may accept different field names or return a different response shape, while both versions call the same application use case. Version the contract when necessary; do not version the business merely because a representation changed.
Design around use cases, not database tables
Table-shaped APIs are tempting because they are quick to expose: create an endpoint for each model, accept whatever columns exist, and let the client assemble workflows. That approach turns the database schema into a public product decision.
Instead, model meaningful actions. POST /orders/{id}/cancel communicates an intent more clearly than a generic update endpoint that accepts {"status":"cancelled"}. The explicit action gives the server a single place to validate permissions, check state, release reservations, and emit any follow-up work.
Database tables should support the domain, not dictate it. A customer-facing API may return an order summary assembled from several tables. Conversely, an internal table may contain audit fields, implementation flags, or transitional columns that should never cross the API boundary.
Keep contracts explicit
Stable backend logic needs explicit inputs and outputs. Avoid passing raw request arrays deep into the application, where optional fields and transport-specific assumptions spread unchecked. Use command objects, value objects, and named result types where they add clarity.
- Validate syntax and required fields at the boundary.
- Validate business rules in the use case or domain model.
- Return application-level outcomes, not framework response objects.
- Map domain failures to HTTP status codes at the API boundary.
For example, “order not found” and “order cannot be cancelled after shipment” are different application outcomes, even if an API chooses to represent them differently. Keeping that distinction preserves useful behavior for every interface that calls the use case.
Make dependencies point inward
Backend logic becomes fragile when it directly depends on a particular database driver, queue library, cache client, or framework model. Those tools are valuable, but they should sit behind interfaces owned by the application.
A use case might depend on an OrderRepository and a TransactionManager, while an infrastructure layer implements them with PostgreSQL and the PHP framework already in use. The core should express what it needs, not how a specific adapter performs it.
This is not a demand for elaborate abstraction around every library. A one-method interface created only to hide a stable utility adds ceremony without buying flexibility. Introduce a boundary where a dependency affects business behavior, testing, deployment, or replacement cost. Persistence, payment providers, email delivery, and external APIs are common examples.
Transactions deserve special care. If cancelling an order changes its state and releases stock, those changes should be coordinated in one transactional operation where the database supports it. If the operation must also publish an event, avoid assuming that a database commit and a message broker publish are one atomic action. An outbox record stored in the same database transaction is often a practical way to record work that can be delivered reliably afterward.
Use migrations as evolution, not interruption
Schema changes are a common reason teams feel forced into a rewrite. The safer approach is to make changes compatible in stages: add new storage, write to both representations if needed, backfill existing data, switch reads, then remove the old path only after it is unused.
This matters in Dockerized deployments because application containers may be replaced independently during a rollout. A deployment that requires every container to run new code at exactly the same instant is brittle. Prefer a period where old and new application versions can both operate against the schema.
Database migrations should run through a controlled deployment step, not automatically from every application container at startup. Multiple replicas racing to apply the same migration is an operational problem, not an architecture strategy. The deployment process should also make failure visible and stop before serving code that requires a migration which did not complete.
Test the core where it matters most
When business rules are isolated from HTTP and infrastructure, most important tests become fast unit or application tests. They can construct an order, execute a use case, and verify the outcome without booting a web server or a full Docker stack.
That does not eliminate integration tests. You still need them for repository queries, migrations, authentication middleware, serialization, and critical external boundaries. The balance matters: use a smaller number of realistic integration tests to prove wiring, and a larger number of focused tests to protect rules.
Performance benefits as well. Slow endpoints are often blamed on PHP or the framework when the real issue is uncontrolled query patterns, oversized response payloads, repeated remote calls, or missing indexes. A clean application boundary makes these costs easier to locate. Measure the actual request path, inspect query counts and timings, and optimize the specific bottleneck rather than scattering caches through the codebase.
Build a backend that can absorb requests
An unchanging backend does not mean an untouched backend. It means the central decisions change slowly because they are expressed in a form that survives new clients, new endpoints, new storage details, and new deployment concerns.
Keep transport code thin. Make business actions explicit. Protect the database from becoming your public API. Introduce infrastructure boundaries where they reduce real risk, and evolve schemas through compatible steps.
Then the next request for “a completely different API” becomes what it usually is: a new adapter around a system whose most valuable logic is already in the right place.