Razvoj

System Design: Architecting for Tomorrow's Unknowns

Dizajn sustava: Arhitektura za sutrašnje nepoznanice

The hardest part of system design is not choosing a database, drawing a diagram, or adding a queue. It is making decisions today without pretending you can see every requirement coming tomorrow.

Good architecture does not eliminate change. It gives change somewhere safe to land. That distinction matters because backend systems rarely fail from a lack of cleverness; they fail when ordinary evolution becomes expensive, risky, and difficult to reason about.

Design for change, not for imagined scale

“Future-proof” is often shorthand for building far more machinery than the current product needs. A simple application acquires event buses, multiple services, distributed tracing, and several data stores before it has enough traffic or organizational complexity to justify any of them.

The more useful goal is optionality: preserve the ability to make a likely future change without committing to every possible future architecture. Start with the simplest design that clearly meets today’s needs, then place boundaries around the areas most likely to evolve.

For a PHP application, that often means a modular monolith rather than immediate microservices. A single deployable can still have clear modules for billing, identity, catalog, notifications, and reporting. Each module owns its business rules and exposes a deliberate interface to the rest of the application.

This is not a compromise architecture. It is a way to keep transactions, deployments, debugging, and local development straightforward while leaving room to extract a module later if its scaling, ownership, or release needs genuinely diverge.

Choose boundaries that reflect the business

Technical layers are useful, but they are not enough. A project organized only into controllers, services, repositories, and models can become difficult to change because unrelated business concepts end up coupled through shared classes and tables.

Instead, begin by asking where the business rules differ. An order is not merely a row in a database. It has rules around pricing, fulfillment, cancellation, payment, and customer communication. Those rules deserve a cohesive home.

A practical module boundary should make a few things clear:

  • Which data and invariants the module owns.
  • Which operations other modules may request.
  • Which events or results it publishes after meaningful changes.
  • Which details remain private implementation choices.

For example, a checkout module should not update inventory tables directly because it happens to know their schema. It should ask the inventory module to reserve stock. This may feel slightly more formal at first, but it prevents a small shortcut from becoming a permanent dependency.

Keep interfaces narrower than implementations

Consumers usually need less than providers want to expose. An API endpoint may only need a product’s public name, price, and availability, not the entire persistence model with supplier notes and internal workflow fields.

Returning intentionally shaped responses reduces accidental coupling and makes schema changes safer. The same principle applies inside the codebase: depend on a small contract instead of a concrete class with dozens of methods.

interface InventoryReservations
{
    public function reserve(string $sku, int $quantity): ReservationResult;
}

The value is not the interface itself. The value is that checkout depends on the capability to reserve inventory, rather than on the database strategy inventory happens to use today.

Let the database enforce what matters

Application code is where business behavior lives, but the database remains the final authority for stored data. Important invariants should not depend solely on a request validator or a controller branch that can be bypassed by a job, import script, or future endpoint.

Use appropriate constraints: primary keys, foreign keys where ownership is stable, unique indexes, non-null columns, and database transactions for changes that must succeed or fail together. If an email address must be unique, make it unique in the database. If a payment record must reference an order, model that relationship explicitly.

At the same time, avoid turning every query into an abstraction exercise. A repository that merely wraps a one-line ORM call adds little protection. Introduce a richer data-access boundary when queries, persistence rules, caching, or transaction behavior are becoming substantial enough to deserve a named home.

Build APIs as long-lived contracts

An API is a promise made to clients you may not control. That includes mobile applications, partner integrations, scheduled scripts, and your own frontend deployed on a different timetable.

Make that promise boring and explicit. Validate input at the boundary, return predictable error formats, define pagination behavior, and avoid exposing database-shaped responses as a public contract. When change is unavoidable, prefer additive changes: introduce a new optional field before removing an old one.

Idempotency deserves particular attention for operations that create money movement, reservations, or external side effects. Networks retry. Clients retry. Queues redeliver messages. A request that is safe once must be designed to be safe when received twice.

$key = $request->header('Idempotency-Key');

if ($existing = $payments->findByIdempotencyKey($key)) {
    return $existing->response();
}

return $database->transaction(function () use ($key, $request) {
    return $payments->createOnce($key, $request->validated());
});

The exact implementation will vary, but the essential detail is durable state protected by a unique constraint or equivalent concurrency-safe mechanism. An in-memory check is not enough when requests can reach multiple application instances.

Use asynchronous work deliberately

Queues are excellent for work that does not need to finish before the user receives a response: sending email, generating exports, processing images, and notifying external systems. They are not a remedy for unclear ownership or unreliable logic.

Every background job needs a failure story. Define retry behavior, ensure repeated execution is safe, capture enough context for diagnosis, and decide what happens after retries are exhausted. A job that silently disappears is worse than a synchronous failure because it creates false confidence.

For events that follow a database update, consider the gap between committing data and publishing a message. If the process stops after one succeeds but before the other, systems can drift. A transactional outbox pattern can help: record the outbound event in the same transaction, then publish it separately and mark it delivered only after success.

Make operations part of the design

Docker can make local environments and deployments more reproducible, but a container is not automatically an operational plan. Configuration should come from the environment, secrets should stay out of images and repositories, and application logs should be structured enough to connect a request, job, and error.

Health checks should answer useful questions. A process being alive is different from the application being ready to serve traffic. Likewise, performance work should begin with observation: measure slow endpoints, inspect database queries, identify contention, then optimize the actual bottleneck.

Premature caching is especially dangerous because it creates invalidation rules before there is evidence that caching is needed. First add the right indexes, eliminate unnecessary queries, paginate large collections, and set sensible timeouts. Cache only when a measured access pattern supports it.

Leave a trail for the next change

Maintainability is not achieved by writing more documentation than code. It comes from making important decisions discoverable: clear names, focused modules, small tests around business rules, migration history, and concise records of architectural choices.

The system you are designing is not a monument. It is a working environment for future developers, including the version of you who no longer remembers why a particular trade-off was made. Architect for that person. Keep boundaries honest, make failure modes explicit, and favor designs that can become more sophisticated only when reality asks them to.

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.