Iza API Gatewaya: projektiranje interakcija usluga za predvidljivo skaliranje
An API gateway can make a distributed system look reassuringly simple: one public endpoint, one authentication layer, one place for routing and rate limits. That simplicity is valuable, but it is also easy to mistake the gateway for the architecture itself.
Predictable scale is determined by what happens after a request crosses that boundary. Which service owns the data? Which calls are synchronous? What happens when a dependency is slow? Can a consumer retry safely? These are interaction-design questions, and they matter more than the number of services in a diagram.
Give every service a clear ownership boundary
A service should own a business capability and the data required to manage it. “User service,” “database service,” or “shared service” are usually warning signs because they describe technical plumbing rather than a responsibility.
For example, an order service may own order state, while an inventory service owns stock reservations. The order service should not update inventory tables directly, even if both happen to use the same database engine. Direct access creates hidden coupling: a schema change in one area can silently break behavior elsewhere.
Ownership also makes failure behavior easier to reason about. If inventory is unavailable, the order service can record a pending state and ask inventory to reserve stock through a defined contract. It does not need to understand inventory indexes, locking rules, or storage migrations.
Use synchronous calls for decisions, not for long chains
Synchronous HTTP or RPC calls are appropriate when a request genuinely needs an immediate answer. Authentication checks, fetching a current price, or confirming that an account may perform an action can fit this model.
The problem starts when one incoming request triggers a chain of dependent calls:
Gateway -> Order -> Customer -> Inventory -> Payment -> Shipping
Each dependency adds latency and another failure point. A healthy service can still fail a request because an unrelated downstream service is overloaded. Under load, retries can amplify the damage by generating even more work against the struggling dependency.
Keep the synchronous path narrow. Ask only for information needed to accept, reject, or accurately acknowledge the request. Move work that can finish later into asynchronous processing.
Design explicit asynchronous workflows
After an order is accepted, the order service can publish an event such as OrderPlaced. Inventory, payment, and fulfillment components can react independently. This does not mean the workflow becomes vague; it means its states must be deliberate.
- Record a durable order state before announcing the event.
- Make consumers idempotent so the same message can be handled more than once.
- Model compensating actions, such as releasing a stock reservation after payment fails.
- Expose meaningful statuses such as
pending_payment,confirmed, andcancelled.
Asynchronous work trades immediate finality for resilience. That trade is often correct, but only when the product experience can explain it honestly. “Order received; confirmation is in progress” is better than returning success while hiding an unreliable chain of work.
Treat retries as a product of contract design
Networks fail in inconvenient ways. A client may time out after the server has already completed the operation. A message broker may deliver a message again. A worker may crash after writing to its database but before acknowledging the queue.
Retries are therefore normal behavior, not exceptional behavior. The contract must make them safe.
For write operations, accept an idempotency key and associate it with the resulting operation. If a client submits the same key again, return the original result rather than creating a second order or charging a card twice. For event consumers, store enough information to recognize that a message has already been applied.
public function createOrder(string $idempotencyKey, array $payload): Order
{
$existing = $this->orders->findByIdempotencyKey($idempotencyKey);
if ($existing !== null) {
return $existing;
}
return $this->transaction->run(
fn () => $this->orders->create($idempotencyKey, $payload)
);
}
The example is intentionally incomplete: the transaction boundary, uniqueness constraint, and concurrent-request behavior must align. In practice, the database should enforce uniqueness for the idempotency key. Application-level checks alone can race when two identical requests arrive at once.
Keep data local, share facts through contracts
A shared database can feel efficient early on, especially in a PHP application evolving from a modular monolith. But it lets services bypass each other’s rules and turns schema migrations into coordinated releases.
A stronger pattern is to let each service own its persistence and publish facts that other services need. A reporting service, for instance, may maintain a read model from business events instead of joining operational tables across multiple domains.
This introduces eventual consistency, so it needs operational discipline. Consumers should tolerate late and duplicate events. Event payloads should include stable identifiers and enough context to be useful. Changes should be additive where possible: add a field before requiring it, and support old consumers until they are retired.
Do not confuse an event with a remote database row. An event should express something that happened in the business domain, not expose every internal field of a table.
Build pressure controls into every boundary
Predictable scale depends on preventing local trouble from becoming system-wide trouble. Gateways help with coarse controls such as authentication, request-size limits, and public rate limits. Internal services need their own protections too.
- Set timeouts for every network dependency; an omitted timeout is an unbounded resource commitment.
- Use bounded queues and worker concurrency that match downstream capacity.
- Apply backoff with jitter for retriable failures, rather than retrying immediately in lockstep.
- Use circuit breaking or load shedding when a dependency is consistently unhealthy.
- Separate critical workloads from noncritical work, such as notifications or analytics.
Docker makes service packaging repeatable, but containers do not remove resource limits. Define CPU and memory expectations, make shutdown handling graceful, and ensure workers stop accepting new work before their process exits. A deployment that interrupts in-flight jobs without recovery semantics is not reliable simply because it runs in containers.
Observe the interaction, not just the service
A dashboard showing that every service is “up” is not enough. Users experience request paths and business outcomes. Instrument correlation IDs across gateway requests, background jobs, and outbound calls. Record latency, error categories, queue depth, retry counts, and the age of unfinished work.
The most useful alerts describe a customer-impacting condition: payment confirmations are delayed, stock reservations are failing, or the queue is growing faster than workers can drain it. Logs remain important, but structured logs with request and domain identifiers are far more useful than isolated exception messages.
Scale the decisions before scaling the infrastructure
The gateway is a front door, not a substitute for good boundaries. Services scale predictably when they own their data, minimize synchronous dependencies, assume retries will happen, and make asynchronous states visible.
The result is not a perfectly failure-free system. It is a system whose failures stay contained, whose behavior can be explained, and whose teams can change one part without turning every deployment into a negotiation. That is the kind of scale worth designing for.