Development

Beyond Rewrite: Building APIs with Predictable Core Logic

Beyond Rewrite: Building APIs with Predictable Core Logic

Most API failures do not begin at the HTTP boundary. They begin when business decisions are scattered across controllers, database queries, framework events, and “just one more” helper method. A rewrite can make that arrangement look cleaner for a while, but it does not automatically make the system easier to reason about.

The more durable goal is predictable core logic: a small, explicit center where important rules live, inputs are validated, outcomes are named, and side effects are controlled. When that core is stable, changing a PHP framework, replacing an ORM query, adding Docker, or exposing a new API version becomes a bounded engineering task instead of a risky excavation.

Make the business decision explicit

An API endpoint should coordinate work, not contain the work’s meaning. A controller may authenticate a request, translate JSON into an input object, call an application service, and convert the result into an HTTP response. It should not decide whether an order can be cancelled, whether a balance may go below zero, or how a pricing rule applies.

Those decisions belong in code that can be invoked without HTTP, a database connection, or a running container. This does not require ceremony for every trivial endpoint. It means identifying the rules that would be expensive to get wrong and giving them a clear home.

final class CancelOrder
{
    public function __construct(
        private OrderRepository $orders,
        private TransactionManager $transactions,
    ) {}

    public function execute(OrderId $orderId, CustomerId $customerId): CancelOrderResult
    {
        return $this->transactions->run(function () use ($orderId, $customerId) {
            $order = $this->orders->get($orderId);

            if (!$order->belongsTo($customerId)) {
                return CancelOrderResult::notFound();
            }

            if (!$order->canBeCancelled()) {
                return CancelOrderResult::notCancellable($order->status());
            }

            $order->cancel();
            $this->orders->save($order);

            return CancelOrderResult::cancelled($order);
        });
    }
}

The point is not the class name. The point is that the cancellation rule is visible, testable, and independent of whether the caller arrived through REST, a command-line job, or a message consumer.

Use boundaries to contain uncertainty

Backend systems interact with uncertain things: client input, network calls, database availability, queues, and clock time. A predictable core does not pretend uncertainty is gone. It keeps it at the edges and represents the meaningful outcomes deliberately.

For example, a missing order is not necessarily an exception. An order that cannot be cancelled because it has shipped is also not necessarily a server failure. Both are valid outcomes of a request. A controller can translate those outcomes consistently:

$result = $cancelOrder->execute($orderId, $customerId);

return match ($result->type()) {
    CancelOrderResultType::CANCELLED => response()->json($result->order(), 200),
    CancelOrderResultType::NOT_FOUND => response()->json(['error' => 'not_found'], 404),
    CancelOrderResultType::NOT_CANCELLABLE => response()->json([
        'error' => 'order_not_cancellable',
        'status' => $result->status(),
    ], 409),
};

Unexpected infrastructure failures are different. A database timeout should be logged, monitored, and handled according to the service’s error policy. Folding it into a generic “cannot cancel order” response hides an operational problem and makes clients behave incorrectly.

Validate at the edge, enforce in the core

Request validation should reject malformed payloads early: missing fields, invalid UUIDs, unsupported enum values, and incorrect data types. But core logic must still protect its invariants. Another caller may bypass the HTTP validator tomorrow, or a previously valid request may become invalid after data changes.

A useful rule is simple: validate shape at the boundary; enforce truth in the domain. The first improves API feedback. The second protects the system.

Transactions are part of the use case

Database transactions are often treated as a repository detail. That is too low-level when a business operation writes multiple records or must preserve a consistency rule. The use case should establish the transactional boundary because it knows what must succeed or fail together.

Consider creating an invoice and reserving inventory. If the invoice is stored but inventory reservation fails, the outcome is not merely inconvenient; it may be semantically wrong. Put the related local writes in one transaction. Keep remote calls out of that transaction where possible, because holding database locks while waiting on another service invites contention and timeouts.

When an external notification must follow a committed write, use an outbox-style approach: store an event record in the same transaction, then let a worker publish it. The worker must tolerate retries, and consumers must tolerate duplicate delivery. A retryable message should have a stable identifier so downstream work can be made idempotent.

  • Commit local state and the event record together.
  • Publish events asynchronously after commit.
  • Retry publication with bounded backoff and observability.
  • Make consumers safe when the same event arrives more than once.

This is less glamorous than a direct HTTP call after a save, but it behaves better when networks fail at exactly the wrong moment.

Let persistence serve the model

An ORM can be productive, but convenience methods should not become the architecture. Queries that encode business meaning deserve names and tests. Database constraints should back up important invariants, especially uniqueness and foreign-key relationships, because application checks alone can lose races under concurrent requests.

For example, checking whether an email address exists and then inserting a user is not enough. Two requests can pass the check simultaneously. A unique database constraint is the final authority; application code should catch and translate its expected conflict into a useful API result.

Performance follows the same principle. Measure the actual query path before adding caches or denormalized tables. Avoid loading whole object graphs merely because the ORM makes it easy. Use pagination with a stable ordering, select only needed fields for read-heavy endpoints, and inspect generated queries when a path becomes important.

Keep deployment boring

Docker is valuable when it makes the runtime contract clear: the PHP version, required extensions, process startup, and configuration inputs should be explicit. It is not a substitute for application design. A container that works locally but depends on undocumented environment variables is still fragile.

Build images predictably, pass configuration through the environment or a supported secret mechanism, and make health checks reflect readiness rather than merely process existence. A PHP process running does not prove that it can reach its database, run required migrations safely, or serve traffic correctly.

Deployment also needs a failure path. Schema changes should be compatible with the currently deployed application whenever rolling deployment is possible. Add a nullable column before requiring it. Write new data before depending on it. Remove old paths only after the system no longer needs them. The safest migration is usually a sequence, not a dramatic cutover.

Predictability is a compounding asset

Good backend architecture is not about maximizing layers or achieving a fashionable diagram. It is about making change understandable. A developer should be able to answer: where is this rule enforced, what outcomes can occur, what data changes together, and what happens if a dependency fails?

When those answers live in explicit core logic, APIs become calmer. Tests become more valuable. Incidents become easier to diagnose. Rewrites become optional rather than inevitable. The best foundation is not the one that never changes; it is the one that lets change happen without turning every release into a guess.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.