ИТ развој

Pragmatic Architecture: Building Software That Outlasts the Hype

Прагматична архитектура: Градење софтвер што ја надживува возбудата

Most software does not fail because its team picked the wrong framework. It fails more quietly: a small shortcut becomes a convention, a convention becomes an assumption, and an assumption becomes expensive to change once real users, real data, and real deadlines arrive.

Pragmatic architecture is the discipline of making today’s decisions without stealing too much from tomorrow. It is not an argument for elaborate abstractions or for “just ship it” minimalism. It is a way to keep a system understandable, adaptable, and reliable while its requirements are still moving.

Start with the shape of the problem

Architecture should follow the pressures a system actually faces. A backend that accepts payments has different failure modes from an internal reporting tool. An API serving a mobile application needs stable contracts. A data-heavy workflow may need careful indexing long before it needs more application servers.

The useful early questions are concrete:

  • What data is important enough to protect, audit, or recover?
  • Which requests must be fast, and which can happen asynchronously?
  • What happens when a dependency is slow, unavailable, or returns the same event twice?
  • Which parts are likely to change because the business is still learning?
  • How will a developer diagnose a failure after it reaches production?

These questions lead to better boundaries than a fashionable architecture diagram. A modest PHP application with a clear domain layer, a relational database, a queue worker, and good observability can be far more resilient than a collection of services whose ownership and failure paths are unclear.

Choose boring boundaries before clever abstractions

For many backend systems, a well-structured monolith is the pragmatic starting point. “Monolith” should not mean one enormous controller layer connected directly to every table. It means one deployable application with deliberate internal boundaries: HTTP handling, application workflows, domain rules, persistence, and integrations.

For example, an order workflow can expose a small application service that coordinates validation, inventory reservation, persistence, and an event for downstream work. The controller translates HTTP input into that workflow; it should not become the place where business rules accumulate.

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

    public function handle(PlaceOrderRequest $request): Order
    {
        return $this->transactions->run(function () use ($request): Order {
            $this->inventory->reserve($request->items);

            $order = Order::fromRequest($request);
            $this->orders->save($order);

            return $order;
        });
    }
}

The exact class names are unimportant. The important part is that the transaction boundary is visible and the workflow can be tested without an HTTP request. External side effects, such as email or webhook delivery, usually belong outside that transaction. Otherwise a slow network call can hold database locks while doing work that can safely be retried later.

Split services for a reason, not a mood

Services earn their operational cost when they need independent deployment, independent scaling, a different security boundary, or genuinely separate ownership. Until then, a modular monolith often provides faster delivery and simpler debugging. A method call is easier to trace than a network request, and a single database transaction is easier to reason about than distributed coordination.

When a boundary becomes real, extract it with a contract, monitoring, and a migration plan. Do not confuse moving code into another repository with creating a reliable service.

Design APIs as long-lived promises

An API is not merely a route that returns JSON. It is a contract that clients may depend on longer than expected. Stable naming, predictable error responses, pagination, and explicit versioning policies reduce accidental breakage.

Validation failures, authorization failures, missing resources, and unexpected server errors should remain distinguishable. Clients need enough information to act, but they should not receive stack traces, internal SQL messages, or implementation details.

For write operations that may be retried, especially from clients or webhooks, idempotency matters. A payment callback delivered twice must not create two paid orders. A practical pattern is to store a unique external event identifier and treat a duplicate insert as an already-processed event, rather than trying to infer duplicates from timestamps or payload similarity.

Backward-compatible change is usually cheaper than forced synchronization. Adding an optional response field is often safe; changing a field’s meaning or type is not. Deprecation is a product and communication process as much as a technical one.

Let the database carry its share of correctness

Application code is valuable, but databases are excellent at enforcing facts that must remain true under concurrency. Use foreign keys where relationships are real, unique constraints where duplication is invalid, and transactions where several changes must succeed or fail together.

A classic mistake is checking for availability in application code and then inserting later without a constraint or suitable locking strategy. Under concurrent requests, both callers can observe the same available state. The database must participate in protecting the invariant.

Performance work follows the same principle: measure the actual query path. Indexes should support known access patterns, not be added by superstition. Examine slow queries, check execution plans, and make sure pagination has an explicit ordering. Offset pagination can become costly on large, frequently changing datasets; cursor-based pagination is often a better fit when clients traverse a stable sort order.

Make delivery repeatable

Docker is useful when it makes local development and deployment more consistent, not when it hides the application behind layers of accidental complexity. A container should declare what it needs clearly: runtime dependencies, configuration supplied through the environment, a predictable startup command, and a writable location only where necessary.

Production readiness also includes migration discipline. Run schema migrations as an explicit deployment step, understand whether each migration is safe with existing traffic, and avoid coupling a code release to a schema change that cannot coexist with the prior version. Expand-and-contract changes are often safer: add a new column or table, deploy code that supports both forms, migrate data, then remove the old path later.

Retries deserve equal care. Retrying a transient network failure can help; retrying every exception can amplify an outage or duplicate side effects. Use bounded retries, backoff, timeouts, and idempotent handlers. Send permanently failing background work somewhere visible so it can be investigated rather than silently discarded.

Optimize for the next person

Maintainability is not a vague preference for tidy code. It is the speed at which someone can safely answer: what does this change affect, how do I test it, and what will happen if it fails?

That means keeping configuration separate from code, logging meaningful context without leaking secrets, exposing health checks that reflect useful dependencies, and writing tests around important behavior rather than private implementation details. It also means deleting abstractions that no longer clarify anything.

The best architecture is rarely the most impressive diagram. It is the one that lets a team make a change with confidence, observe its consequences, recover when reality disagrees, and keep learning without rebuilding the whole system. Hype fades quickly. Clear boundaries, honest trade-offs, and systems that are easy to operate have a much longer half-life.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.