ИТ развој

Stop Architecting for Features, Start Building for Flow

Престанете да проектирате за функции, почнете да градите за проток

Many backend systems become difficult long before they become large. The usual cause is not traffic, team size, or an exotic database problem. It is a design habit: treating every requested feature as a new architectural object.

A feature arrives, so a new service appears. A new endpoint gets its own orchestration layer. A small variation in a workflow becomes another set of tables, flags, events, and exceptions. Individually, each choice looks reasonable. Together, they create a system that is optimized for explaining its parts rather than moving work reliably from request to outcome.

Strong architecture is less about how many boundaries you can draw and more about how smoothly the important flows travel through them.

Features are nouns; flow is a verb

Feature-oriented design naturally centers on nouns: subscriptions, invoices, exports, notifications, profiles, permissions. Those nouns matter, but users experience verbs. They sign up, pay, import, search, retry, download, and recover.

A flow-oriented architecture starts by asking what must happen from beginning to end. Consider a customer requesting an account export:

  • The API authenticates and authorizes the request.
  • The system records that an export was requested.
  • A worker gathers data without holding an HTTP connection open.
  • The generated file is stored safely.
  • The customer receives a durable way to retrieve it.
  • Failures can be retried without producing confusing duplicates.

That is the real unit of design. The export endpoint, queue job, storage adapter, database record, and notification are supporting details. If those details are designed independently, the happy path may work while retries, partial failures, and support investigations become painful.

Map the path before selecting the pattern

Before introducing a service, event, abstraction, or new database model, write the flow in plain language. Include the states, the owner of each transition, and what happens when a dependency is slow or unavailable.

A useful test is whether a developer unfamiliar with the code can answer these questions:

  • What starts this operation?
  • Where is its current state recorded?
  • Which step may be repeated safely?
  • What does success look like?
  • What does failure look like to a user and to an operator?
  • How is incomplete work found and resumed?

If the answers require searching through controllers, listeners, cron jobs, and several repositories, the system may have boundaries without a coherent flow.

This does not mean every operation needs a formal workflow engine. Most PHP applications benefit from something simpler: an explicit state field, a small application service, a transaction boundary, and a queue job for work that should not happen during the request.

Make state visible

Hidden state is one of the most expensive forms of complexity. A record marked completed while its downstream notification silently failed leaves both the user and the support team with an ambiguous system.

Prefer meaningful, observable states such as requested, processing, ready, and failed. They should describe the business operation, not merely the last method that ran. A state transition should also have a clear rule: who may make it, under which conditions, and whether it is safe to attempt again.

DB::transaction(function () use ($accountId) {
    $export = AccountExport::create([
        'account_id' => $accountId,
        'status' => 'requested',
    ]);

    GenerateAccountExport::dispatch($export->id);
});

The important idea is not the framework syntax. It is the contract: the request is recorded before asynchronous work begins. If dispatching requires stronger delivery guarantees than a best-effort queue handoff, use a durable outbox-style record and publish it separately. Do not assume a database commit and an external queue operation are one atomic action unless your infrastructure explicitly provides that guarantee.

Design APIs around completion, not controller shape

An API endpoint is a promise about a flow. For quick, deterministic operations, a synchronous response is appropriate. For slow or variable work, pretending that every request can finish inside one HTTP response creates timeouts, memory pressure, and brittle retry behavior.

For a long-running operation, return an accepted response with a resource that can be inspected later. The client can poll a status endpoint, or receive a notification through a channel the product already supports. The exact transport matters less than a stable lifecycle.

POST /account-exports
Authorization: Bearer <token>

HTTP/1.1 202 Accepted
Location: /account-exports/exp_123

Idempotency belongs in this conversation. Network clients retry. Load balancers retry. Humans click twice. If creating the same operation twice is harmful, accept an idempotency key and associate it with the request’s outcome. The retry should return the original result, not create a second export, charge, or email sequence.

Keep transaction boundaries honest

Database transactions are excellent at protecting related local changes. They are not a general-purpose solution for coordinating remote APIs, queues, email providers, or object storage.

A common failure path looks like this: code saves a payment record, calls an external service, then commits. If the remote call succeeds but the database commit fails, the remote world and local world disagree. Reversing the order merely changes which inconsistency is possible.

Flow-oriented design acknowledges this reality. Record intent locally, perform remote work with an idempotency token where available, store the result, and make reconciliation possible. For sensitive operations, build a clear repair path rather than relying on a single uninterrupted execution.

This mindset also improves database design. Tables should preserve the information needed to explain the current state and recover from failure. A single overloaded status column can be fine at first, but do not discard timestamps, failure reasons, attempts, or external reference IDs when they are essential to operating the flow.

Use Docker to make the flow reproducible

Containers are most valuable when they reduce environmental uncertainty. A PHP application flow often depends on more than PHP: a web server, database, cache, queue worker, scheduler, and sometimes local object storage or a mail catcher.

The goal is not to reproduce production with maximum ceremony. The goal is to let a developer start the dependencies required to exercise an important path and understand which process owns which responsibility.

Make the worker and scheduler explicit. If a queued export only succeeds because a developer happens to run a command in another terminal, that dependency is part of the architecture whether it is documented or not.

services:
  app:
    build: .
  worker:
    build: .
    command: php artisan queue:work
  scheduler:
    build: .
    command: php artisan schedule:work

Names and commands will vary by framework, but the principle holds: model the processes that move the flow forward. Then provide health checks, logs, and runbook-level instructions appropriate to the system’s operational needs.

Optimize the path that matters

Performance work is also easier when framed as flow. Do not begin with “we need caching.” Begin with the slow or expensive path: which query, serialization step, remote call, lock, or queue backlog is extending the user-visible outcome?

Measure at boundaries. Track request duration, queue delay, job duration, database query behavior, and error rates where those signals are available. Add indexes because the actual access pattern needs them, not because a table feels important. Cache stable, frequently read data with clear invalidation rules, not entire responses whose correctness depends on hidden user context.

Flow thinking prevents local optimizations that simply move cost elsewhere. A faster endpoint that creates an unbounded queue backlog is not faster in the way users care about.

Let boundaries serve the journey

Modules, services, repositories, events, and APIs are valuable tools. They become harmful when they exist mainly to satisfy a diagram. A boundary earns its cost when it clarifies ownership, isolates a changing concern, or makes a critical flow safer to evolve.

Build around the work your system must reliably complete. Make its state explicit. Expect retries and partial failure. Keep asynchronous steps observable. Then choose the smallest architecture that makes that journey clear.

Features will keep arriving. If the flow remains understandable, each one has somewhere sensible to go.

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

Mihajlo

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