Razvoj

Beyond the Prompt: Architecting APIs for Real-World Evolution

Iza prompta: projektiranje API-ja za evoluciju u stvarnom svijetu

The first version of an API often feels deceptively complete. A controller accepts input, calls a service, returns JSON, and everyone moves on. Then the API meets reality: a mobile client lingers on an old release, a partner needs an extra field, a database migration takes longer than expected, and a “small” rename becomes a breaking change across several systems.

Prompts can produce endpoints quickly. Engineering begins when those endpoints must survive change. The central design question is not “Does this request work today?” It is “How can this contract evolve without surprising its consumers or trapping its maintainers?”

Think of the API as a Product Boundary

An API is a boundary between independent rates of change. The backend may be deployed several times a day; consumers may update monthly, or never. That difference makes every exposed field, error format, status code, pagination rule, and authentication behavior part of a product contract.

Internally, a domain model can be reshaped as the system learns. Externally, changes should be deliberate. A resource named customer should not suddenly expose the database’s latest naming convention, and a refactor should not force clients to understand new table relationships.

This is why response objects deserve their own mapping layer. In PHP, returning an ORM entity directly from a controller is convenient until a new relationship, hidden attribute, serialization rule, or lazy-loaded property leaks into production behavior. A dedicated transformer or resource class makes the public shape explicit.

final class CustomerResource
{
    public static function from(Customer $customer): array
    {
        return [
            'id' => (string) $customer->id,
            'name' => $customer->displayName,
            'email' => $customer->email,
            'created_at' => $customer->createdAt->format(DATE_ATOM),
        ];
    }
}

The database can store display_name, split it into separate columns later, or source it from another service. Clients still receive the contract they were promised.

Prefer Additive Change Over Replacement

The safest API change is usually additive: introduce a new optional field, endpoint, filter, or capability while preserving existing behavior. Removing or changing the meaning of an existing field is expensive because clients cannot reliably adapt until they know the change happened.

Consider a shipping address. Adding country_code is generally compatible if clients can ignore unknown properties. Replacing an existing country field with a two-letter code is not compatible, even if the new value is more useful. The old field may need to remain until consumers have had a reasonable migration path.

Versioning is useful, but it is not a substitute for disciplined evolution. A URL such as /v2/orders can establish a clear new contract, especially for a genuinely different model. It can also become an escape hatch for avoidable churn, leaving several expensive API generations to maintain forever.

Use a new major version when the meaning or structure of a resource must materially change. For smaller changes, favor these options:

  • Add an optional field with documented semantics.
  • Add a new endpoint when a new workflow does not fit the old resource.
  • Introduce an optional request parameter with a stable default.
  • Deprecate old behavior visibly before removing it.
  • Offer a migration window and communicate a specific replacement.

Design Errors for Humans and Programs

Error handling is where otherwise thoughtful APIs often become difficult to integrate. A plain 500 tells neither a developer nor an automated client what happened next. Error responses should be predictable enough to parse and specific enough to act on.

{
  "error": {
    "code": "validation_failed",
    "message": "The request contains invalid fields.",
    "details": {
      "email": ["Must be a valid email address."]
    }
  }
}

The human-readable message is useful for logs and dashboards. The stable machine-readable code lets clients distinguish validation errors from expired credentials or rate limits without parsing prose. Field-level details help forms highlight the right input.

Status codes should reinforce that clarity. Use 400 for malformed requests, 401 when authentication is missing or invalid, 403 when an authenticated caller lacks permission, 404 when the resource is not available to that caller, and 422 for well-formed input that fails validation. Most importantly, apply the chosen rules consistently.

Make Writes Safe to Retry

Networks fail in inconvenient places. A client may time out after the server creates a payment, order, or subscription but before the response reaches the caller. Retrying the request must not silently create a duplicate.

For important create operations, support idempotency keys. The client sends a unique key with the request; the server records the first completed result for that key and returns the same outcome if the request is repeated. The key must be scoped carefully, commonly by authenticated account and operation, so unrelated callers cannot collide.

This behavior requires more than an HTTP header. The storage design must handle concurrent requests for the same key, failures before completion, expiration of old records, and a mismatch where a key is reused with different request data. A unique database constraint and a transaction are often more trustworthy than an in-memory lock, particularly in Docker deployments with multiple application containers.

Transactions Protect Invariants, Not Just Queries

When an operation changes several tables, wrap the relevant writes in a transaction. For example, creating an order may reserve inventory, persist the order, and write an outbox event. If any step fails, the system should not retain a half-created business action.

Do not publish an external message directly inside the transaction and assume it is reliable. The database may commit after the publish fails, or the publish may succeed before the transaction rolls back. An outbox table lets the transaction save both the business change and a pending event; a separate worker can deliver the event with retries.

Pagination and Performance Are Contract Decisions

Returning every record is easy until it is not. Pagination should be designed before a collection becomes large, because adding it later changes client assumptions and response handling.

Offset pagination is familiar, but it can become slow for deep pages and can skip or repeat records when data changes during browsing. Cursor pagination is often a better fit for large, ordered feeds. A cursor should be based on a stable sort order, such as creation time plus a unique identifier, rather than an unstable display field.

Performance also depends on what the API asks the database to do. Avoid loading a collection and then triggering one query per item for related data. In PHP applications using an ORM, inspect endpoint query counts and eager-load relationships intentionally. Add database indexes for real filter and sort patterns, not merely because a column seems important.

Caching belongs behind clear ownership rules. Cache public or safely shared reads when invalidation is understood; avoid caching authorization-sensitive responses under keys that omit the caller’s identity. A fast incorrect response is worse than a slower correct one.

Build an Evolution Process, Not Just an API

Maintainable APIs have a repeatable change process. Keep a machine-readable contract where practical, test representative client behavior, and review changes for compatibility as part of normal code review. Contract tests should cover more than successful JSON: validation failures, authorization boundaries, pagination links or cursors, and empty results matter too.

Operational signals close the loop. Structured logs with request identifiers, latency measurements, error rates, and database query visibility make it possible to see whether a new endpoint is healthy after deployment. Docker makes local environments reproducible, but production correctness still depends on configuration, migrations, secrets, health checks, and rollback-aware deployment practices.

The durable API is not the one with the most endpoints or the cleverest abstraction. It is the one that treats change as inevitable, protects consumers from incidental internal details, and gives maintainers room to improve the system underneath. That discipline turns an endpoint from a momentary answer into infrastructure people can safely build on.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.