Skip to content

Architecture and domain-driven design

Status: Complete. Last reviewed 2026-08-28.

Architecture assigns policy, data, and operational responsibility to boundaries that can evolve safely. Domain-driven design (DDD) supplies modelling practices for domains where language, rules, and organizational boundaries are difficult. Neither is a folder template, a mandate for microservices, nor a way to avoid database and delivery mechanics.

Architecture records consequential constraints: which module owns a decision, which data it may change, which dependencies point inward, and where failure can be isolated. A layered architecture groups presentation, application, domain, and infrastructure responsibilities. Hexagonal or ports-and-adapters architecture instead emphasizes dependency direction: application/domain policy declares ports; HTTP, Eloquent, queues, and providers implement adapters around it. These views can coexist inside one deployment.

DDD begins with a model expressed in the language used by domain experts and software. Its value rises when rules are subtle, terms conflict, and policy changes frequently. CRUD-heavy supporting areas may be clearer as conventional Laravel code. “Pure domain” ceremony around straightforward record maintenance increases mappings and indirection without protecting meaningful rules.

Strategic DDD identifies subdomains and bounded contexts, their ownership, and relationships. Core subdomains deserve disproportionate modelling investment; supporting and generic capabilities can use simpler models or purchased services. Tactical DDD supplies entities, value objects, aggregates, services, repositories, and domain events inside a context. Applying tactical patterns everywhere without strategic boundaries produces an elaborate coupled monolith.

A bounded context is the boundary within which a model and its language are consistent. “Customer” can mean a billing account in Invoicing and a recipient with delivery preferences in Fulfilment. Forcing one canonical Customer model makes either module depend on fields, states, and release decisions it does not own.

Find contexts by tracing business capabilities, decisions, language conflicts, data ownership, change cadence, and team responsibility. Database tables and namespaces are evidence, not answers. A context should own writes to its authoritative model; other contexts consume an explicit API, message, or local published interface. Shared reads may use projections, but direct cross-context table updates bypass invariants and make schema changes coordinated deployments.

Relationships need an explicit context map. A conformist accepts an upstream model; an anti-corruption layer translates an external or legacy model; a published language stabilizes exchanged concepts; a shared kernel deliberately shares a small model and therefore requires coordinated governance. Translation has latency and maintenance cost, but it prevents foreign terminology and failure behavior spreading through core policy.

An entity has continuity through identity even as attributes change. Equality and references therefore use identity appropriate to the context, not every current field. A value object is defined by its values and invariants. Making money, date ranges, or addresses immutable prevents partially valid mutation and keeps validation close to construction; persistence representation remains a separate concern.

An aggregate is a consistency boundary around one root and a set of invariants enforced atomically. External code addresses the root rather than mutating internals independently. It is not every object reachable by an ORM relationship. A large Order graph containing customer, catalogue, inventory, payment, and shipment data creates long transactions, contention, stale snapshots, and impossible cross-team ownership.

Choose aggregate boundaries from rules that must be immediately consistent. An order may ensure its line totals equal its stated total in one transaction, while inventory reservation and payment settle through separate owners and explicit workflow states. Reference other aggregates by identity. If a rule can tolerate delay, enforce it through events, process state, and reconciliation rather than one distributed aggregate.

Concurrency completes aggregate design. Loading a root, checking a rule, and saving does not prevent two requests from both passing the check. Use a transaction plus locking, an optimistic version predicate, or a database constraint according to the invariant. If updates routinely contend or require loading thousands of children, the boundary or command shape needs review.

An application service orchestrates a use case: authenticate/authorize the actor, load roots, invoke domain behavior, control the transaction, persist changes, and arrange integration work. It should expose the business operation rather than contain every rule. A domain service holds stateless domain policy that does not naturally belong to one entity or value object, such as allocation across several candidates. Calling every class Service hides this distinction.

A domain event states a meaningful fact that occurred inside the model, such as ReservationExpired. It can decouple policy from reactions, but it does not guarantee asynchronous delivery or cross-service publication. Capture it during the aggregate operation, persist state and any required outbox record atomically, and define which adapter publishes an integration event. In-process listeners are unsuitable for effects that must survive a crash after commit.

Domain events use the language and detail of their context. Integration events form a versioned contract for other contexts and should reveal only stable facts. Do not serialize entities or Eloquent models into either. For delivery, deduplication, ordering, schema evolution, and reconciliation mechanics, use distributed delivery and reliability rather than assuming the DDD label supplies them.

A port is an application-owned contract for an interaction the policy needs: load an aggregate, reserve credit, publish committed facts, or obtain a clock. An inbound adapter translates HTTP, CLI, or queue input into an application command. An outbound adapter implements a port using Eloquent, SQL, a broker, or an external API. The dependency points inward because the adapter depends on the port’s semantics.

Ports should describe domain/application capability, not mirror vendor APIs. CreditDecision assess(CustomerId, Money) can protect policy from provider DTOs and exceptions; VendorClientInterface::post() cannot. Translate timeouts, unknown outcomes, and errors without erasing information the application needs to decide retry or refusal.

Do not demand an interface for every class. Stable in-process domain collaborators can be concrete. Ports earn their mapping and test costs where a boundary crosses framework lifecycle, persistence, network, time, or independently changing ownership. The Laravel container belongs in outer wiring, while the model remains constructible without global resolution.

A modular monolith deploys as one application while maintaining explicit internal ownership. It avoids network failure and distributed operations between modules, but process co-location alone does not preserve boundaries. Enforce them with top-level namespaces/packages, narrow public entry points, dependency rules in static analysis, architecture tests, and code ownership. Keep migrations and tables visibly owned even when modules share one database.

Cross-module calls should enter through published application interfaces; cross-module writes go through the owning module. Database permissions per module can be impractical in one Laravel process, so automated dependency checks, review, and query telemetry provide enforceable approximations. Ban imports of another module’s internal namespace and prevent new foreign-key-driven object navigation from becoming an accidental public API.

Transactions across modules are possible in one database but increase coupling. Use them deliberately when the business invariant truly spans those modules and accept coordinated evolution. Otherwise commit through one owner and communicate a fact. Asynchronous communication inside a monolith still has delivery and observability costs; do not add a broker merely to make a boundary look architectural.

Extracting a service exchanges in-process coupling for network, data, deployment, security, and operational contracts. Valid triggers include independent scaling with measured saturation, isolation of a damaging failure mode, regulatory or security separation, a stable capability owned by an autonomous team, or release cadence blocked by coordinated deployment. Team size, fashionable architecture, or a large table count are weak triggers.

Before extraction, establish the boundary inside the monolith. Identify the service’s commands, queries, invariants, data owner, latency/availability contract, and callers. Remove direct writes and hidden joins first. Instrument traffic and failure behavior. If the boundary cannot be enforced in one process, a network will not fix it; it will make violations slower and harder to debug.

Extraction adds timeouts, retries, authentication, authorization, discovery, capacity planning, dashboards, on-call ownership, versioned contracts, test environments, and degraded-mode decisions. Reads that were joins may require API composition or projections. Writes that were one transaction become workflows with idempotency and reconciliation. Include these permanent costs in the decision, not only the first code move.

Migrate ownership without split-brain writes

Section titled “Migrate ownership without split-brain writes”

A safe data migration names one authoritative writer at every stage. Commonly, backfill historical data into the new store with checkpoints, continuously replicate new changes through a log/outbox, verify counts and domain invariants, then shadow reads and compare results. Route a small cohort of reads to the new owner before increasing traffic.

At cutover, fence old writers, drain or version in-flight work, switch the authoritative path, and retain a tested rollback that does not create two divergent writers. Dual writing from application code is dangerous: either write can fail, retries can reorder them, and rollback cannot infer which side is authoritative. If temporary dual writes are unavoidable, make one side authoritative and derive the other through a replayable, monitored channel.

Reconcile by business keys, versions, totals, and missing/extra records, not row counts alone. Preserve old identifiers and event compatibility for delayed messages. Delete old tables and paths only after the rollback window, audit needs, and lagging consumers are resolved. Migration success is sustained ownership and operability, not one matching snapshot.

  • Current: PHP 8.5 and Laravel 13 provide useful type and framework mechanisms, but bounded contexts and aggregate guarantees remain application design decisions. A modular monolith is a strong default when independent deployment pressure is unproven.
  • Common: Laravel 11–12 systems mix Eloquent models, application services, jobs, events, and a few domain-rich modules. Incremental boundaries around active change are usually safer than a wholesale “clean architecture” rewrite.
  • Legacy: Shared database models, cross-module writes, synchronous listener chains, and service-oriented systems with a shared schema are common. Build a context map and observe dependencies before moving code; preserve contracts while transferring ownership in stages.