Software design
Status: Complete. Last reviewed 2026-08-28.
Software design arranges behavior and dependencies so a change has a predictable, limited cost. It does not maximize interfaces, classes, or patterns. A useful design makes policy visible, protects the invariants that matter, and leaves evidence when its assumptions stop being true.
Coupling and cohesion predict change cost
Section titled “Coupling and cohesion predict change cost”Coupling is the set of assumptions one unit makes about another. Source imports and method calls are only the visible part: callers can depend on data shape, transaction timing, exception behavior, call order, latency, deployment, or a framework lifecycle. Two classes connected through an interface remain tightly coupled if every change to one requires a coordinated change to the other.
Cohesion asks whether behavior and data grouped together serve one focused responsibility. A cohesive pricing policy contains the rules and inputs that change when pricing changes. A Helpers class containing currency formatting, authorization, and retries is not cohesive merely because its methods are short. Low cohesion usually produces shotgun changes; excessive fragmentation produces navigation and wiring cost.
Measure these qualities through change history and production behavior. Repeated commits touching the same files suggest they may belong together. One policy change touching controllers, jobs, models, and listeners suggests the policy is scattered. A module with many consumers, unstable output shapes, or ordering-sensitive callbacks has consequential coupling even if its dependency graph looks tidy. Optimize for the coupling the business actually pays for, not zero coupling, which is impossible in a working system.
SOLID describes behavior, not class shapes
Section titled “SOLID describes behavior, not class shapes”The single-responsibility principle groups behavior that changes for the same reason. Count policy owners and sources of change, not methods. Splitting a checkout into five one-method services does not help when one pricing change still modifies all five; keeping validation and calculation together can be more cohesive when one policy owner changes both.
The open-closed principle is useful when an axis of variation is known and recurring. A stable checkout can accept a promotion policy while new promotions vary behind that contract. Building plugin points for hypothetical variation makes ordinary changes cross factories, registrations, interfaces, and tests. The extension mechanism earns its cost only while it reduces edits to stable policy.
The Liskov substitution principle is behavioral. A subtype must preserve the promises clients rely on: accepted inputs, results, invariants, side effects, and failure behavior. A ReadOnlyRepository that implements save() by throwing for every call is not substitutable for a writable repository. A cache adapter that silently returns stale values where the contract promises authoritative reads also violates the client contract even though its types match.
Interface segregation gives each client the capabilities it needs. Separate read and write contracts when consumers genuinely have different authority or stability needs, not to ensure every interface has one method. Dependency inversion says high-level policy should not depend directly on volatile detail; it should accept an application-owned boundary whose semantics fit the policy. These are diagnostic principles, not a scorecard: applying one can increase indirection or make another concern worse.
Dependency direction, injection, and containers
Section titled “Dependency direction, injection, and containers”Dependency direction is a source-level and semantic choice: pricing policy can depend on a TaxRates capability, while an HTTP tax-provider adapter depends inward on that capability and translates provider behavior. Dependency injection is how an object receives a dependency rather than constructing or locating it. Constructor injection makes mandatory dependencies and object validity visible; method injection suits operation-specific input; a service locator hides requirements behind runtime lookups.
Inversion of control is broader: a framework may call application middleware, commands, or event handlers. A dependency-injection container constructs graphs and selects bindings. It can implement wiring, but it does not create dependency inversion automatically. Binding TaxRates to a vendor class while application policy imports vendor DTOs leaves the semantic dependency pointing outward.
Keep container resolution at composition roots such as application bootstrap, controller construction, or worker startup. Domain and application code should normally receive already constructed collaborators. Hidden app() calls make tests depend on global configuration, defer missing-binding failures until execution, and permit dependencies to change by ambient scope. Long-running workers make accidental singleton state especially visible through cross-request leakage.
Prefer composition around stable contracts
Section titled “Prefer composition around stable contracts”Inheritance couples a subtype to protected state, lifecycle hooks, and undocumented sequencing in its parent. Use it when there is a real substitutable relationship and the base contract is designed for extension. Prefer composition when behavior varies independently: a checkout composed with pricing, tax, and payment capabilities can replace one concern without inheriting an entire workflow.
Composition is not automatically simple. A chain of decorators adds behavior such as caching, retries, metrics, or authorization around a service, but order becomes behavior: retrying outside metrics counts attempts differently; caching outside authorization can leak results; a transaction decorator around a network retry can hold locks. Make ordering explicit in one composition root and test the combined failure paths, not only each decorator alone.
Closures, enums, and data tables are legitimate composition tools. A strategy class is useful when an algorithm has meaningful state, dependencies, independent tests, or runtime selection. A closure may be clearer for one small variation. The pricing-policy comparison shows the decision: extract around a demonstrated axis of change, not around every conditional.
Select patterns from forces and failure modes
Section titled “Select patterns from forces and failure modes”| Need | Candidate | Cost or failure mode |
|---|---|---|
| Interchangeable policy | Strategy | Selection and extra types can exceed the variation’s value. |
| Translate an external contract | Adapter | Vendor DTOs or exceptions leaking inward defeat the boundary. |
| Add orthogonal behavior | Decorator | Wrapper order and duplicated retries become hidden semantics. |
| Represent a requested action | Command | Empty action classes can obscure a direct application call. |
| Construct validated variants | Factory | A global factory can become a service locator. |
| Notify independent observers | Event/observer | Ordering, ownership, transaction timing, and failure become less visible. |
Name the force before the pattern: what changes, who owns it, and what must remain stable? Then trace failure. An event is appropriate when publishers should not know every independent reaction and delayed handling is acceptable. It is poor camouflage for a required synchronous step whose failure the caller must report. A factory earns its place when construction enforces variants or selects implementations; it does not justify hiding ordinary constructor arguments.
Patterns combine, so costs compound. An event handled by a command that selects a strategy through a factory may be warranted, but every hop weakens local discoverability. Production evidence includes traces with many tiny spans, container-resolution failures, circular dependencies, duplicated retries, and changes requiring edits to registration plus several pass-through layers.
Abstractions are liabilities until they buy a boundary
Section titled “Abstractions are liabilities until they buy a boundary”An abstraction has creation, naming, navigation, wiring, debugging, testing, and migration costs. It is valuable when it owns stable semantics over volatile details, removes duplicated policy, restricts capability, or creates a credible test and replacement boundary. Mere delegation is not decoupling.
Delay an abstraction when examples are too few to reveal what is stable. Duplication of a small expression can be cheaper than coupling unrelated policies to one premature helper. Introduce it when changes repeatedly move together or boundary translation is already causing defects. Remove it when implementations no longer vary, callers bypass it, its contract mirrors one dependency, or engineers must change both abstraction and detail for every feature.
Removal should be evidence-led. Characterize externally observable behavior, inline or replace callers in small steps, compare queries and side effects, then delete registrations and dead tests. Watch latency, error classification, and transaction boundaries: a pass-through layer may contain an undocumented retry or eager load. An abstraction removed safely reduces concepts without silently changing the contract.
Repository and Eloquent trade-offs
Section titled “Repository and Eloquent trade-offs”Eloquent is Active Record: models expose persistence and query behavior alongside row-shaped state. A repository can add value when it gives application policy an application-owned contract, returns domain values or projections, centralizes a complex query guarantee, or isolates a genuinely replaceable data source. For example, Reservations::claimAvailable(...) can own locking and result semantics more usefully than exposing a generic find().
A repository that mirrors find, save, and delete, returns Eloquent models, and accepts Eloquent builders adds ceremony while preserving framework coupling. Generic repositories also erase query-specific needs, encourage leaky “criteria” languages, and can hide N+1 queries or unbounded collections. Do not claim easy database replacement: query semantics, constraints, transactions, and migrations make persistence substitution expensive regardless of interfaces.
Use Eloquent directly in cohesive application code when CRUD and framework conventions are the clearest contract. Extract a named query object for a complex read projection; use a repository when domain policy needs a narrower persistence port. Test important mappings, locks, and constraints against the production database. A fake repository proves policy under its model, not Eloquent or SQL behavior. See Eloquent model mechanics for lifecycle and bulk-write consequences.
Current and legacy context
Section titled “Current and legacy context”- Current: PHP 8.5 and Laravel 13 support strong native types, enums, readonly constructs, attributes, constructor promotion, and container autowiring. These reduce boilerplate but do not prove behavioral substitutability or good dependency direction.
- Common: Laravel 11–12 applications mix direct Eloquent application code, container-bound services, actions, repositories, jobs, and events. Judge each boundary by its contract and failure behavior rather than its suffix.
- Legacy: Static facades, service locators, deep inheritance, mutable global state, generic repositories, and large service classes often carry shipped behavior. Characterize that behavior and introduce seams around active change; a wholesale pattern rewrite creates risk without evidence of value.
Interview practice
Section titled “Interview practice”- ARCH-DESIGN-01 — Diagnose coupling and cohesion in a change
- ARCH-DESIGN-02 — Apply SOLID behaviorally
- ARCH-DESIGN-03 — Separate dependency inversion, injection, and containers
- ARCH-DESIGN-04 — Select a pattern from production forces
- ARCH-DESIGN-05 — Remove an abstraction whose cost exceeds its value
- ARCH-DESIGN-06 — Decide whether a repository adds value over Eloquent