Architecture العربية

arc42 §5 — Building Block View (C4 Level 3)

Status: Draft v0.1 · Date: 2026-07-20 Traces to: CON-14 (modular monolith), QAS-MOD-03 (extraction), R-08 (boundary erosion), SEAM-01SEAM-07

Why this document is the most important one for long-term cost. R-08 is the only risk in the register marked effectively irreversible. Modular monoliths do not fail because the boundaries were drawn wrong — they fail because nothing stopped them eroding. This document defines the boundaries and the machinery that enforces them. A boundary that is not machine-checked is a comment.


5.1 Module Decomposition#

flowchart TB
    subgraph EDGE["HTTP Edge"]
        API["API Controllers · Form Requests · Resources"]
        ADMUI["Admin Portal"]
    end

    subgraph MODULES["Business Modules — each owns its data"]
        direction TB
        IAM["<b>Identity</b><br/>users · credentials · tokens<br/>sessions · actor profiles"]
        ONB["<b>Onboarding</b><br/>documents · review queue<br/>approval state machine"]
        CONS["<b>Consultancy</b><br/>consultant profiles · availability<br/>bookings · sessions"]
        VID["<b>Video</b><br/>session lifecycle · tokens<br/>provider port"]
        TPL["<b>Documents</b><br/>templates · categories<br/>custom requests"]
        LRN["<b>Learning</b><br/>modules · lessons<br/>assignments · progress"]
        ASMT["<b>Assessment</b><br/>question banks · attempts<br/>grading"]
        CERT["<b>Certification</b><br/>certificates · verification<br/>revocation"]
        MKT["<b>Directory</b><br/>provider listings · categories<br/>featured placements · indexing"]
        PAY["<b>Billing</b><br/>payments · subscriptions<br/>payouts · payment port"]
        NOTIF["<b>Notifications</b><br/>dispatch · preferences<br/>templates"]
        ADM["<b>Administration</b><br/>admin roles · queues<br/>audit · reporting"]
    end

    subgraph SHARED["Shared Kernel — value objects &amp; interfaces ONLY"]
        SK["EventBus · DomainEvent · Money · Locale<br/>Clock · TenantContext · typed IDs"]
    end

    subgraph INFRA["Infrastructure"]
        DB[("PostgreSQL<br/>module-prefixed tables · RLS")]
        REDIS[("Redis")]
        OBJ[("Object Storage")]
        SRCH[("Search Engine")]
    end

    API --> MODULES
    ADMUI --> ADM
    MODULES --> SHARED
    MODULES --> INFRA

    style SHARED fill:#fff4e5,stroke:#d98c1f
    style IAM fill:#e8f0fe,stroke:#1168bd

Module catalogue#

Module Owns (tables prefixed) Public contract exposes Extraction readiness
Identity identity_* — users, credentials, tokens, actor profiles IdentityQuery (user summaries, batch), AccountState 🟢 High — pure, few inbound deps
Onboarding onboarding_* — documents, versions, reviews, transitions AccountApprovalStatus, events 🟢 High
Consultancy consultancy_* — consultant profiles, availability, bookings BookingQuery, ConsultantSummary 🟡 Medium — couples to Billing + Video via events
Video video_* — sessions, participant events, provider refs VideoSessionPort (SEAM-02) 🟢 Highest — already a port
Documents documents_* — templates, sections, items, custom requests TemplateQuery, CustomRequestStatus 🟢 High
Learning learning_* — modules, lessons, assignments, progress AssignmentQuery, ProgressSummary 🟢 High (SEAM-04)
Assessment assessment_* — banks, questions, attempts AssessmentResultnever the answer key 🟢 High (SEAM-04)
Certification certification_* — certificates, revocations CertificateQuery, public verification 🟢 High
Directory directory_* — listings, categories, featured placements ListingQuery, FeaturedStatus 🟡 Medium — owns search projection (SEAM-05)
Billing billing_* — payments, subscriptions, statements PaymentPort (SEAM-01), SubscriptionStatus 🟢 Highest — already a port
Notifications notifications_* — dispatch log, preferences NotificationDispatcher 🟢 High — pure sink
Administration admin_* — admin roles, queues, audit ledger, read models — (consumes only) 🔴 Low — reads across all modules by design

Administration is deliberately the least extractable module and that is correct. It is a cross-cutting operational surface, not a domain. It consumes public contracts and projections from everywhere. Trying to make it independently deployable would either duplicate every module's data or turn every admin screen into a fan-out of network calls.


5.2 Internal Module Structure#

Every module has the same shape. The Public/ vs Internal/ split is the load-bearing convention.

src/Modules/Consultancy/
├── Public/                      ← the ONLY namespace other modules may import
│   ├── Contracts/
│   │   └── BookingQuery.php     ← interface, batch-first
│   ├── Events/
│   │   ├── BookingConfirmed.php ← primitives only, versioned
│   │   └── ConsultationCompleted.php
│   └── Dto/
│       └── ConsultantSummary.php ← immutable, no Eloquent
├── Internal/                    ← invisible to every other module
│   ├── Models/                  ← Eloquent models live HERE and never leave
│   ├── Services/
│   ├── Repositories/
│   ├── Policies/
│   └── StateMachines/
├── Http/                        ← controllers, requests, resources
├── Jobs/
├── Database/
│   └── migrations/              ← this module's tables, prefixed
├── Providers/
│   └── ConsultancyServiceProvider.php  ← binds Public contracts to Internal impls
└── Tests/

One rule does most of the work#

No module may reference another module's Internal namespace.

It is unambiguous, mechanically checkable, and produces a clear error message. That single Deptrac rule is worth more than a style guide, because it cannot be argued with in code review.


5.3 Dependency Rules#

flowchart TB
    subgraph L1["Layer 1 — no module dependencies"]
        IAM2["Identity"]
        NOTIF2["Notifications"]
    end
    subgraph L2["Layer 2 — depends on Layer 1 only"]
        ONB2["Onboarding"]
        TPL2["Documents"]
        LRN2["Learning"]
        ASMT2["Assessment"]
        MKT2["Directory"]
        PAY2["Billing"]
        VID2["Video"]
    end
    subgraph L3["Layer 3 — orchestrating"]
        CONS2["Consultancy"]
        CERT2["Certification"]
    end
    subgraph L4["Layer 4 — consumes everything"]
        ADM2["Administration"]
    end

    L2 --> L1
    L3 --> L2
    L3 --> L1
    L4 --> L3
    L4 --> L2
    L4 --> L1

    SK2["Shared Kernel<br/><i>value objects + interfaces only</i>"]
    L1 & L2 & L3 & L4 --> SK2

    style SK2 fill:#fff4e5,stroke:#d98c1f

Rules#

# Rule Enforced by
1 Dependencies point downward only. No upward, no cycles. Deptrac layer rules
2 A module may import only another module's Public/. Deptrac namespace rule
3 Shared Kernel may depend on nothing. Deptrac
4 No cross-module foreign key constraints. Store the bare ID. Migration review + schema test
5 No cross-module Eloquent relationship or whereHas/join. Architecture test
6 Never pass an Eloquent model across a boundary. Pass a DTO. Architecture test (Public/ may not reference Internal\Models)
7 No database transaction spanning modules. Review + event-driven design
8 Only Identity may reference the User class. Architecture test

Rule 8 deserves its own explanation#

The User model is the specific trap that kills Laravel modular monoliths. In a typical application every module hangs relations off User until it has forty methods and imports from every namespace — at which point extracting anything means touching everything.

Instead: Identity owns User. Every other module stores user_id as a plain column and holds its own local projection where it needs one — Consultancy\ConsultantProfile, Directory\ProviderProfile, Learning\Learner. Each is keyed by user_id, owned by its module, and carries only what that module cares about.

This feels redundant on day one. It is what makes extraction possible on day one thousand.


5.4 Inter-Module Communication#

Two mechanisms, and the choice between them is not stylistic.

flowchart LR
    subgraph SYNC["Synchronous — query, needs an answer now"]
        A["Module A"] -->|"IdentityQuery::findSummaries([ids])"| B["Module B<br/>Public/Contracts"]
        B -->|"UserSummary[]"| A
    end

    subgraph ASYNC["Asynchronous — fact, already happened"]
        C["Module C"] -->|"publish(BookingConfirmed)"| OUT["Transactional<br/>Outbox"]
        OUT --> REL["Relay"]
        REL --> D["Module D<br/>subscriber"]
        REL --> E["Module E<br/>subscriber"]
    end

    style OUT fill:#e8f5e9,stroke:#2d8659
Use When
Public contract (sync) You need data now to complete the current operation
Domain event (async) Something happened; other modules may care; the originator must not know who

Contracts are batch-first#

interface IdentityQuery {
    public function findSummary(UserId $id): ?UserSummary;

    /** @return array<string, UserSummary> keyed by id */
    public function findSummaries(UserId ...$ids): array;
}

A single-fetch-only contract is fine in-process and becomes an N+1 storm of HTTP calls the day the module is extracted. Designing for batch now costs one extra method.

The transactional outbox — ship this in week one#

sequenceDiagram
    participant M as Module
    participant DB as PostgreSQL
    participant R as Relay
    participant S as Subscribers

    M->>DB: BEGIN TX
    M->>DB: Write state change
    M->>DB: Write outbox_events row
    M->>DB: COMMIT
    Note over DB: State + event committed atomically.<br/>Either both happen or neither.
    R->>DB: Poll unpublished events
    R->>S: Dispatch (in-process today,<br/>broker tomorrow)
    S-->>R: Ack
    R->>DB: Mark published

Why this is the highest-leverage day-one investment (~1 day of work):

  • Atomicity. A booking cannot be confirmed without its event, and an event cannot fire for a booking that rolled back. Publishing outside the transaction produces exactly those two bugs, intermittently.
  • The broker swap is free later. Publisher and subscriber code never changes — only the relay's destination.
  • Replay and audit come along for the ride.

Event design#

interface DomainEvent {
    public function eventName(): string;              // 'consultancy.booking_confirmed'
    public function occurredAt(): DateTimeImmutable;
    public function payload(): array;                 // PRIMITIVES ONLY
    public function version(): int;                   // from day one
}

Two non-negotiables:

  1. Payloads are primitives. ['booking_id' => 123, 'consultant_id' => 45], never a model. A payload that serialises cleanly to JSON today is a broker message tomorrow at zero cost. A payload holding an Eloquent model is a rewrite.
  2. Version from day one. Adding it after you have subscribers is a migration; having it and ignoring it costs nothing.

5.5 Event Catalogue#

The published inter-module vocabulary. This is the integration contract.

Event Published by Consumed by
identity.user_registered Identity Notifications, Administration
identity.credentials_changed Identity Notifications
onboarding.documents_submitted Onboarding Administration (queue)
onboarding.account_approved Onboarding Identity, Directory, Consultancy, Notifications
onboarding.account_rejected Onboarding Identity, Notifications
consultancy.booking_created Consultancy Billing
consultancy.booking_confirmed Consultancy Video, Notifications, Administration
consultancy.booking_cancelled Consultancy Billing, Video, Notifications
consultancy.consultation_completed Consultancy Billing (payout eligibility), Administration
video.session_ended Video Consultancy
video.connection_failed Video Consultancy, Administration
documents.template_downloaded Documents Administration (KPI)
documents.custom_request_paid Documents Administration (queue)
documents.custom_request_delivered Documents Notifications, Administration
learning.assignment_created Learning Notifications
learning.module_completed Learning Assessment, Administration
assessment.attempt_completed Assessment Learning, Administration
assessment.attempt_passed Assessment Certification, Notifications
certification.certificate_issued Certification Notifications, Administration
certification.certificate_revoked Certification Notifications, Administration
directory.listing_changed Directory (self — reindex)
billing.payment_captured Billing Consultancy, Documents, Directory, Administration
billing.payment_failed Billing Notifications, Administration
billing.subscription_lapsed Billing Directory (delist), Notifications
billing.featured_expired Billing Directory (reindex)

Read the Certification row. assessment.attempt_passed → Certification is the only path by which a certificate comes into existence. Certification never reaches into Assessment's tables. That one arrow is why SEAM-04 works.


5.6 Enforcement in CI#

This section is the point of the document. Everything above is advisory without it.

flowchart LR
    PR["Pull Request"] --> D["Deptrac<br/><i>--fail-on-uncovered</i>"]
    D --> A["Architecture tests<br/><i>AST-level, per-module</i>"]
    A --> S["Schema test<br/><i>no cross-module FKs</i>"]
    S --> T["Cross-tenant<br/>adversarial suite"]
    T --> P["PHPStan"]
    P --> OK["✅ Merge allowed"]

    D -.->|violation| FAIL["❌ Build fails"]
    A -.->|violation| FAIL
    S -.->|violation| FAIL
    T -.->|leak| FAIL

    style FAIL fill:#fde8e8,stroke:#c94a4a
    style OK fill:#e8f5e9,stroke:#2d8659

Deptrac#

⚠️ Package moved. qossmic/deptrac is abandoned. Use deptrac/deptrac (v4.6.2, 2026-07-01). Composer warns on the old name; a stale tutorial will put the wrong one in composer.json.

Rules to configure:

  1. Each module is a layer; Public/ and Internal/ are sub-layers
  2. Module → module allowed only via Public/
  3. Layer ordering per §5.3 — no upward edges, no cycles
  4. Shared/ may depend on nothing
  5. --fail-on-uncovered — without it, a new namespace nobody assigned to a layer silently escapes every rule. This is the single most common way a Deptrac config decays.

Architecture tests (AST-level, fast, per-PR)#

Test Catches
Only Identity references User The ball-of-mud trap (rule 8)
No Public/ class references Internal\Models Model leakage across boundaries
Every model with a company_id column uses the tenant-scoping trait The tenant leak added next year
Every model has a registered policy Unauthorized endpoints
No env() outside config/ Breaks config:cache silently in production
No mutable static state; no Application/Request/Config injected into singletons Keeps the code Octane-adoptable later
Assessment delivery resources contain no correctness fields QAS-INT-01 answer-key leak

Schema test#

Asserts no foreign key constraint crosses a module's table prefix. Runs against the migrated schema, not the migration files — so it catches constraints added by a package or a hand-written migration.


5.7 Shared Kernel — the discipline that keeps it small#

Shared/
├── Contracts/    EventBus · DomainEvent · Clock · TenantContext
├── ValueObjects/ Money · Locale · UserId · CompanyId · Percentage
└── Enums/        Currency · Country · ActorType

Hard rule: Shared/ contains only value objects and interfaces with no dependencies.

The moment Shared/ contains a service with behaviour, it becomes the new ball of mud — every module depends on it, it depends on everything, and the dependency graph is a lie. Deptrac rule 4 exists specifically to prevent this, and it should be treated as inviolable rather than as a guideline with exceptions.

TenantContext belongs here because it is cross-cutting by nature, and because it throws when read while unset rather than returning null — the fail-closed behaviour QAS-SEC-01 depends on.


5.8 Extraction Playbook#

When a module must become an independently deployable service, the boundaries above reduce it to five mechanical steps:

flowchart TB
    S1["1. Swap the contract binding<br/><i>local impl → HTTP/gRPC client</i><br/>Callers unchanged"]
    S2["2. Point the outbox relay<br/>at a real broker<br/><i>Publishers/subscribers unchanged</i>"]
    S3["3. Move the module's tables<br/><i>No cross-module FKs to drop</i>"]
    S4["4. Deploy as a service<br/><i>same image, different entrypoint</i>"]
    S5["5. Replace the reconciliation job<br/>with cross-service checks"]
    S1 --> S2 --> S3 --> S4 --> S5

Estimated cost: ≤ 20 person-days per module (QAS-MOD-03) — provided the rules held. If they did not, the estimate is meaningless and the real number is a rewrite.

Accepted trade-offs#

Cost Bought back by
No cross-module referential integrity Domain events for cascades + a reconciliation job reporting orphans as a metric, not a failed write
Local projections duplicate some data Each module owns exactly what it needs; no distributed join
More boilerplate — DTOs, contracts, events Extraction stays a refactor rather than a rewrite
Slower initial development CONF-04's Phase-2 scope makes boundary erosion the highest-probability expensive failure

5.9 What This Does Not Prescribe#

Recorded so the boundaries of this document are clear:

  • No internal module architecture is mandated. Whether a module uses services, actions, or a richer domain model is the module's own business. The contract is its Public/ surface, not its internals.
  • No premature abstraction. A module with one implementation gets one implementation. Ports exist where a decision is deliberately deferred (SEAM-01 payments, SEAM-02 video) — not everywhere.
  • CQRS, event sourcing, and hexagonal purity are not required. The outbox is there for atomicity and extraction, not because events are fashionable.

The goal is not architectural elegance. It is that the Phase-2 scope in CONF-04 — POS/PMS, inventory, CRM, government integration — can be built additively, by a team that may not include anyone who wrote the MVP.

Tech Lead · Amir Haroun Draft v0.1 · research & design only