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-01…SEAM-07
Why this document is the most important one for long-term cost.
R-08is 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 & 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 |
AssessmentResult — never 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
Internalnamespace.
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:
- 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. - 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 whySEAM-04works.
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/deptracis abandoned. Usedeptrac/deptrac(v4.6.2, 2026-07-01). Composer warns on the old name; a stale tutorial will put the wrong one incomposer.json.
Rules to configure:
- Each module is a layer;
Public/andInternal/are sub-layers - Module → module allowed only via
Public/ - Layer ordering per §5.3 — no upward edges, no cycles
Shared/may depend on nothing--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-01payments,SEAM-02video) — 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.