System Architecture: Embracing Pragmatism Over Dogma
Architecture discussions can become strangely religious. One team treats microservices as the only serious option. Another insists that every system must begin as a modular monolith. Someone proposes an event bus before the first customer has completed a workflow. Someone else rejects any abstraction that was not needed yesterday.
Good architecture is not a contest to prove that a pattern is pure. It is the ongoing practice of making useful trade-offs while preserving the ability to make better ones later.
For backend engineers, pragmatism is not an excuse for careless shortcuts. It means understanding the cost of a decision: operational burden, failure modes, developer speed, database consistency, security, performance, and the likelihood that requirements will change. The best design is usually the simplest one that handles today’s real constraints without making tomorrow unnecessarily expensive.
Start with constraints, not a preferred pattern
Before choosing services, queues, databases, or deployment topology, define what the system actually needs to do. The constraints may be technical, but they are often shaped by the product and the team.
- How many users and requests must the system support now, and what growth is plausible?
- Which workflows require strong consistency, and which can tolerate delay?
- What data is sensitive, regulated, or difficult to recover?
- How many engineers will maintain the system and operate it when something fails?
- What is more expensive: delayed delivery, downtime, incorrect data, or slow response time?
A small PHP application with a conventional relational database can support a surprising amount of business complexity. It can provide authentication, billing integrations, background jobs, reporting, and a well-designed API without immediately needing separate deployable services. Splitting it too early often creates new problems: distributed tracing, duplicated deployment configuration, versioned contracts, network failures, and complicated local development.
That does not make a monolith universally correct. A component that needs independently scalable workers, has a distinct reliability boundary, or is maintained by a separate team may deserve isolation. The point is that the boundary should solve a known problem, not merely resemble an architecture diagram from a larger company.
Build a modular monolith before distributing complexity
A monolith is not automatically tangled, just as microservices are not automatically modular. The useful distinction is whether the code has understandable boundaries.
In a PHP backend, organize behavior around domains rather than around technical layers alone. An order module, for example, can own order creation, pricing rules, persistence operations, and events related to orders. Other modules should interact through deliberate interfaces instead of reaching directly into internal tables or classes.
final class CreateOrder
{
public function __construct(
private OrderRepository $orders,
private PaymentGateway $payments
) {
}
public function handle(CreateOrderRequest $request): Order
{
$order = Order::fromRequest($request);
$this->payments->authorize($order->total());
$this->orders->save($order);
return $order;
}
}
The exact framework and directory layout matter less than the discipline behind them. Keep HTTP controllers thin. Avoid putting business rules into database migrations, templates, or framework callbacks. Make dependencies visible. Write tests around meaningful behavior. These choices make future extraction possible if a module truly needs to become a service.
Extraction should be a consequence of pressure, not a speculative investment. When a boundary is already clear inside the monolith, moving it behind an API or queue is difficult but comprehensible. When the boundary does not exist, service extraction tends to relocate confusion rather than remove it.
Choose databases for correctness before novelty
Database decisions are another place where fashion can overwhelm judgment. A relational database is often the most pragmatic default for operational systems because transactions, constraints, joins, and mature tooling directly address common business needs.
If an invoice must have a valid customer, represent that relationship with a foreign key where appropriate. If a balance must not become negative, consider whether the invariant belongs in application logic, transactional updates, or a database constraint. A clean-looking object model does not protect data when concurrent requests arrive at the same time.
Pragmatism also means using specialized storage when the workload justifies it. A search index may improve discovery. An object store may suit uploaded files. A cache may reduce repeated expensive reads. But each additional datastore introduces ownership questions: which copy is authoritative, how is data synchronized, how are failures repaired, and how will backups and access controls work?
A cache should generally be treated as disposable. If deleting it corrupts the product, it was not merely a cache; it was an undocumented primary datastore.
Design APIs as contracts, not controller output
An API becomes architecture the moment another client depends on it. That client may be a browser, mobile application, partner integration, command-line tool, or another internal process. Stable contracts deserve deliberate design.
Use explicit request validation, predictable error responses, and clear authorization checks. Keep transport concerns separate from domain behavior. If an endpoint creates a resource, decide what happens when a client retries after a timeout. A duplicate payment or duplicate order is not a minor edge case; it is a normal consequence of unreliable networks.
Idempotency keys can make retry-sensitive operations safer. For example, a client can send a unique key with a payment request, and the server can return the original outcome when that key is seen again. The implementation must store the key and outcome reliably enough to survive the relevant retry window. Merely accepting a header without enforcing the behavior adds false confidence.
Versioning should be proportionate too. A version in every URL is not a substitute for compatibility discipline. Prefer additive changes where possible, document behavior clearly, and remove fields only after consumers have had a realistic migration path.
Use Docker to reduce differences, not hide them
Docker is valuable when it makes development, testing, and deployment environments more repeatable. A container image can package a PHP runtime, extensions, and application dependencies so that the software runs with fewer surprises across machines.
It does not remove operational concerns. Configuration still needs to be provided safely. Database migrations still need coordination. Logs must reach a place operators can inspect. Secrets should not be baked into images. A container that starts successfully may still fail because it cannot reach its database, has no write permission for a required directory, or is using stale configuration.
Keep the deployment path boring enough to understand under pressure. Make health checks reflect meaningful readiness when possible. Separate build-time artifacts from runtime configuration. Test the migration and rollback plan before a production incident forces the issue.
Measure before optimizing
Performance work is most effective when it begins with a visible bottleneck. An endpoint may be slow because of an unindexed query, repeated database access, excessive serialization, an external API call, or a queue worker that is not keeping up. Each problem calls for a different remedy.
In PHP applications, common wins include avoiding N+1 queries, selecting only needed columns, adding indexes that match actual query patterns, moving nonessential work to background jobs, and caching carefully chosen read-heavy results. None of these should be applied blindly. An index speeds some reads while increasing write cost. A queue improves response time while introducing asynchronous failure handling. Caching reduces load while creating invalidation rules.
The practical question is not, “What is the fastest architecture?” It is, “What is slow enough to matter, why is it slow, and what change improves it without creating a larger problem?”
Keep optionality where change is likely
Maintainability is largely the ability to change a system safely. That does not require building extension points for every imagined future. It requires identifying the areas most likely to change: payment providers, pricing rules, third-party integrations, notification channels, and reporting needs.
Use interfaces where there is a real alternative or a credible boundary. Use configuration for genuine environment differences. Avoid abstractions that only wrap a single method and make code harder to follow. The goal is not maximum flexibility; it is useful flexibility.
Architecture earns its value when it makes the next important change safer, clearer, or cheaper.
Pragmatic systems are not simplistic. They take reliability seriously, acknowledge uncertainty, and keep complexity attached to a demonstrated need. Begin with clear boundaries, protect data integrity, design for ordinary failures, and measure the problems that users actually feel. Then let the architecture evolve in response to evidence. That is less dramatic than dogma, but it is how software stays useful long after its first diagram has become outdated.