Shared Kernel — Deep Beginner's Guide

The Shared folder,
fully explained

The Shared folder is the foundation every module in your app stands on. Understanding it means understanding how DDD modules talk to each other without becoming entangled — explained from zero, using your actual code.

🔷 7 Value Objects — Uuid, Coordinates, Address, Money, Email, PhoneNumber, AggregateRoot
📬 Event Bus — Contract + Laravel implementation
🔌 1 Provider — SharedKernelServiceProvider
Foundation
What is the Shared Kernel?

The Shared folder is a small, carefully chosen collection of building blocks that every module in your application needs — but that belong to no single module.

The plain English explanation

Imagine you're building a city. Each neighbourhood (Driver module, Merchant module, Delivery module) has its own streets, buildings, and rules. But the whole city shares a few things: a common language (English), a standard for addresses (street, city, country), a common currency (KWD), and emergency radio frequencies (the event bus).

Nobody owns those things — they belong to the whole city. If every neighbourhood invented its own address format, a delivery driver from one neighbourhood couldn't find addresses in another. That's exactly the problem the Shared Kernel solves in code.

🏗️ The Standard Parts Factory Analogy

A car manufacturer has many factories: one makes engines, one makes doors, one makes tyres. But they all use the same standard bolt sizes, the same electrical connectors, and the same quality testing framework. These standards are not owned by any one factory — they live in a shared "standards department." Your Shared/ folder is that standards department.

What exactly is in it?

🔷 Shared building blocks

Things every module uses but nobody owns:

Uuid — every entity in every module needs a UUID.

Coordinates — both Merchant addresses and Driver locations use GPS.

Money — both Merchant billing and Driver pay use money.

Email — both Merchant and Driver have contact emails.

Address — pickup and dropoff addresses in the Delivery module.

📡 Shared communication infrastructure

The system modules use to talk to each other:

DomainEvent — the base class every event inherits from.

DomainEventBus — the interface for firing events.

AggregateRoot — the base class every Aggregate extends.

DomainException — the base class every business exception extends.

The technical name: Shared Kernel

In DDD, this pattern has a formal name: Shared Kernel. Eric Evans (the inventor of DDD) defined it as:

"A subset of the domain model that two or more teams agree to share. This kernel is explicitly designated as a small slice of the domain model that both teams agree to keep synchronised."

The key word is small. The Shared Kernel must be tiny. Only put something in Shared if at least two modules genuinely need it and it contains no module-specific business logic.

How is Shared different from the other modules?

QuestionDriver / Merchant modulesShared Kernel
Who owns it?One team, one bounded contextNobody — the whole project
Contains business logic?Yes — KYC rules, GPS status rulesNo — only structural / technical code
Changes often?Yes — as business rules evolveRarely — changes must be agreed by all teams
Uses other modules?No — modules are isolatedNever — Shared has no dependencies
ExamplesDriver.php, KycStatus.phpUuid.php, Money.php, DomainEvent.php
Foundation
Why does Shared exist? What problem does it solve?

Without a Shared Kernel, every module would have to reinvent basic building blocks — and they'd all do it differently. Let's see exactly what goes wrong.

Problem 1 — Duplication without a standard

// ❌ WITHOUT Shared — every module reinvents Uuid differently

// Driver module version:
class DriverUuid {
    public function __construct(public readonly string $value) {}
}

// Merchant module version:
class MerchantId {
    public function __construct(private string $id) {}
    public function getValue(): string { return $this->id; }
}

// Delivery module version:
class OrderUuid {
    public $uuid;
    // forgot to validate! invalid UUIDs accepted ❌
}

// ✅ WITH Shared — one standard, used everywhere
use DeliveryApp\Shared\Domain\ValueObjects\Uuid;

$driverId   = new Uuid('550e...'); // ✅ validated
$merchantId = new Uuid('661f...'); // ✅ same class
$orderId    = Uuid::generate();        // ✅ same factory method

Problem 2 — Modules can't communicate without a common event language

When a merchant's KYC is approved, the Notification module needs to know. When a driver goes online, the Dispatch module needs to know. These modules must fire and receive events — but how?

They need a shared language for events: a base class that every event extends, and a common bus that every module uses to fire them. Without this, each module would invent its own event system and they could never understand each other.

Problem 3 — Money bugs in production

// ❌ WITHOUT Shared/Money — every module does its own math

// Merchant module calculates delivery fee:
$fee = 12.50 + 3.75;  // float addition — might give 16.249999... ❌

// Driver module calculates earnings:
$earnings = 8.5 * 1.2; // 10.200000000000001 in floating point ❌

// Payment module rounds differently: results mismatch ❌

// ✅ WITH Shared/Money — integer minor units, no float drift
$fee      = Money::fromMajor(12.50, 'KWD');  // stored as 1250 cents
$tip      = Money::fromMajor(3.75, 'KWD');   // stored as 375 cents
$total    = $fee->add($tip);                     // 1625 cents = exactly KWD 16.25 ✅
✅ The three problems Shared Kernel solves

1. Duplication: Write Uuid, Money, Email, Coordinates once — used everywhere correctly.

2. Communication: Modules fire and listen to events using a shared language.

3. Correctness: Validation and formatting rules defined once — impossible to get wrong in individual modules.

Foundation
The complete folder structure

Every file in Shared, with its purpose at a glance.

Shared/ ← The Shared Kernel — owned by nobody, used by all │ ├── Application/ ← EMPTY now — ready for shared use-case helpers │ └── .gitkeep │ ├── Domain/ ← Pure PHP — zero Laravel, zero Eloquent │ │ │ ├── Contracts/ ← Interfaces the Domain needs but can't implement itself │ │ └── DomainEventBus.php ← "I need to fire events" — but HOW is Infrastructure's job │ │ │ ├── Events/ │ │ └── DomainEvent.php ← Base class for ALL events in ALL modules │ │ │ ├── Exceptions/ │ │ └── DomainException.php ← Base class for ALL business rule violations │ │ │ └── ValueObjects/ ← Shared building blocks — immutable, validated │ ├── AggregateRoot.php ← Base for Driver, Merchant, Order aggregates │ ├── Uuid.php ← ID standard for every entity in the system │ ├── Coordinates.php ← GPS lat/lng + Haversine distance calculation │ ├── Address.php ← Structured address + embedded Coordinates │ ├── Money.php ← Safe money math using integer minor units │ ├── Email.php ← Validated, normalised email address │ └── PhoneNumber.php ← E.164-format phone number with validation │ └── Infrastructure/ ← Technical implementations of Domain contracts ├── Bus/ │ └── LaravelDomainEventBus.php ← Connects DomainEventBus contract → Laravel dispatcher ├── Eloquent/ ← EMPTY — shared Eloquent helpers go here if needed │ └── .gitkeep └── Providers/ └── SharedKernelServiceProvider← Registers DomainEventBus binding in Laravel DI container

How Shared connects to the modules

// Every module imports FROM Shared — Shared never imports FROM modules

// Driver/Domain/Entities/Driver.php
use DeliveryApp\Shared\Domain\ValueObjects\AggregateRoot;  // ← from Shared
use DeliveryApp\Shared\Domain\ValueObjects\Uuid;           // ← from Shared
use DeliveryApp\Shared\Domain\ValueObjects\Coordinates;    // ← from Shared

final class Driver extends AggregateRoot { ... }

// Merchant/Domain/Entities/Merchant.php
use DeliveryApp\Shared\Domain\ValueObjects\AggregateRoot;  // ← same Shared class
use DeliveryApp\Shared\Domain\ValueObjects\Uuid;           // ← same Shared class
use DeliveryApp\Shared\Domain\ValueObjects\Money;          // ← from Shared
use DeliveryApp\Shared\Domain\ValueObjects\Email;          // ← from Shared

final class Merchant extends AggregateRoot { ... }

// Dependency direction (Shared is the innermost layer):
//
//   Driver module  ──→  Shared Kernel
//   Merchant module──→  Shared Kernel
//   Delivery module──→  Shared Kernel
//                            ↑
//                    NO arrows go outward from Shared
Domain / Contracts
DomainEventBus.php — the contract

This is one of the most important files in the entire project. It defines how modules announce that something happened — without knowing who is listening or how the announcement works.

What is a Contract?

A contract (PHP interface) is a promise, not an implementation. It says: "Whatever implements me, I guarantee these methods will exist." The Domain writes contracts for capabilities it needs. Infrastructure provides the actual working code.

📞 The Telephone Analogy

When you pick up a phone and dial, you don't know if the call travels over copper wire, fibre optic cable, satellite, or 5G towers. You just know: "I speak into this device and the other person hears me." The phone socket is the contract. The technology inside the wall is the implementation. DomainEventBus is the socket. LaravelDomainEventBus is the wiring in the wall.

The code — line by line

// Shared/Domain/Contracts/DomainEventBus.php
interface DomainEventBus
{
    // Fire ONE event: "Something just happened"
    public function dispatch(DomainEvent $event): void;

    // Fire ALL events collected by an aggregate after a transaction
    // @param iterable<DomainEvent> $events
    public function dispatchAll(iterable $events): void;
}Shared/Domain/Contracts/DomainEventBus.php

That's the entire file — 8 lines. But these 8 lines do something profound:

Without this interfaceWith this interface
Every Service imports Illuminate\Contracts\Events\Dispatcher directly Every Service imports DomainEventBus — a Domain concept, not a Laravel concept
Domain layer depends on Laravel — cannot run without it Domain layer depends on its own interface — runs without Laravel in tests
To swap event systems (e.g. move to RabbitMQ), modify every Service To swap: write a new RabbitMQDomainEventBus, change one binding in the provider

How it's used — the full chain

// 1. Application Service declares it needs an event bus (via interface)
final class ApproveMerchantKycService
{
    public function __construct(
        private readonly MerchantRepository $repository,
        private readonly DomainEventBus $eventBus,  // ← interface, not Laravel class!
    ) {}

    public function execute(Uuid $merchantUuid): void
    {
        $merchant = $this->repository->findByUuid($merchantUuid);
        $merchant->approveKyc();                                  // Entity records event
        $this->repository->save($merchant);                       // DB saved ✓
        $this->eventBus->dispatchAll($merchant->pullDomainEvents()); // Events fired ✓
    }
}
// 2. Laravel injects LaravelDomainEventBus (registered by SharedKernelServiceProvider)
// 3. LaravelDomainEventBus forwards to Laravel's own dispatcher
// 4. Laravel's dispatcher calls all registered Listeners
// Service never knows any of this — it just calls $this->eventBus->dispatchAll()The full chain

Why does this interface live in Shared/Domain instead of each module?

Because every module in the system fires events. Driver services fire events. Merchant services fire events. Future Delivery and Tracking services will fire events. If you put DomainEventBus in the Driver module, the Merchant module would have to import from Driver — creating a cross-module dependency. Put it in Shared, and everyone imports from a neutral place.

Why is it in Contracts/ not Services/?

Contracts/ holds interfaces that represent capabilities the Domain needs from the outside world. The Domain cannot implement them itself (because the implementation requires Laravel or infrastructure tools). This is the Dependency Inversion Principle in action: the Domain defines what it needs, and Infrastructure provides it.

Domain / Events
DomainEvent.php — the base of all events

Every event in your entire system — DriverWentOnline, MerchantKycApproved, future OrderCreated, DeliveryCompleted — extends this single class. It's the common language all modules use to announce what happened.

The code — every line explained

// Shared/Domain/Events/DomainEvent.php
abstract class DomainEvent
{
    // Every event knows WHEN it happened — auto-set on creation
    public readonly DateTimeImmutable $occurredAt;

    public function __construct(?DateTimeImmutable $occurredAt = null)
    {
        // If you don't pass a time, it uses NOW automatically
        // You can pass a time for replaying historical events
        $this->occurredAt = $occurredAt ?? new DateTimeImmutable();
    }

    // Every event must have a human-readable name
    // Used for logging, debugging, event store entries
    abstract public function eventName(): string;

    // Every event must be serialisable to an array
    // Used for: logging, queuing, API responses, audit trails
    abstract public function toArray(): array;
}Shared/Domain/Events/DomainEvent.php

How a real event inherits from it

// Driver/Domain/Events/DriverWentOnline.php
final class DriverWentOnline extends DomainEvent  // ← extends Shared base class
{
    public function __construct(
        public readonly Uuid $driverId,
        public readonly DriverLocation $location,
    ) {
        parent::__construct();  // sets $occurredAt = now()
    }

    public function eventName(): string
    {
        return 'driver.went_online';  // fulfils abstract requirement
    }

    public function toArray(): array
    {
        return [                        // fulfils abstract requirement
            'driver_id'   => (string) $this->driverId,
            'lat'         => $this->location->coordinates->latitude,
            'lng'         => $this->location->coordinates->longitude,
            'recorded_at' => $this->location->recordedAt->format(DATE_ATOM),
        ];
    }
}Driver/Domain/Events/DriverWentOnline.php

Domain Events vs Laravel Events — what's the difference?

AspectDomain Event (your DomainEvent.php)Laravel Event (plain class + event())
What it representsA business fact: "Driver went online"A technical trigger: "Send email"
Where it's createdInside a Domain Entity (recordEvent())Anywhere — Service, Job, Controller
When it firesAfter DB transaction commits (explicit dispatch)Immediately when event() is called
Depends on Laravel?No — pure PHP, testable aloneYes — requires Laravel framework
In your projectDomain Events get dispatched through LaravelDomainEventBus which hands them to Laravel's dispatcherLaravel's dispatcher then calls your Listeners
💡 The relay race

Think of it as a relay race. Your Domain Event is the baton — it carries the business fact. The DomainEventBus is the first runner who hands the baton off. The LaravelDomainEventBus is the hand-off to Laravel's dispatcher. The Listeners are the final runners who do the actual work (send email, update analytics). The baton (DomainEvent) never changes throughout the race.

Why is DomainEvent abstract?

Because you can never fire a generic "something happened" event — every event must be specific. The abstract keyword forces every child class to implement eventName() and toArray(). If a developer creates MerchantKycApproved and forgets toArray(), PHP throws a fatal error at load time — before any code runs. It's a safety net.

Domain / Exceptions
DomainException.php — the base of all business errors

When a business rule is violated, the Domain throws a DomainException. This one abstract class makes it possible for Laravel's error handler to know: "this is a business rule violation — return HTTP 422."

The code

// Shared/Domain/Exceptions/DomainException.php
abstract class DomainException extends RuntimeException
{
    // That's it. No methods. No properties.
    // It's a MARKER — a tag that says "I am a business rule violation"
}Shared/Domain/Exceptions/DomainException.php

The hierarchy — all business errors in your project

DomainException  (Shared — the root marker)
├── InvalidDriverStatusTransition   (Driver module: "busy → online not allowed")
└── InvalidKycTransitionException    (Merchant module: "pending → approved not allowed")

// Future exceptions you'd add:
// ├── InsufficientCreditException   (Merchant module: "not enough balance")
// ├── DriverNotAvailableException   (Driver module: "driver is suspended")
// └── DeliveryAreaNotCoveredException (Delivery module: "we don't deliver there")Exception hierarchy

What is "system error" vs "business error"?

TypeExampleHTTP StatusClass
System errorDatabase connection failed, disk full, null pointer500 Internal Server ErrorPHP built-in exceptions
Business error"You can't go online while busy", "KYC not submitted yet"422 Unprocessable EntityDomainException subclass
Input error"lat must be a number", "email is required"422 Validation ErrorLaravel ValidationException

How Laravel catches it — the global handler

// app/Exceptions/Handler.php (or bootstrap/app.php in Laravel 11+)
use DeliveryApp\Shared\Domain\Exceptions\DomainException;

$exceptions->render(function (DomainException $e) {
    // Catches ANY subclass of DomainException automatically:
    // InvalidDriverStatusTransition, InvalidKycTransitionException, etc.
    return response()->json([
        'error'   => 'business_rule_violation',
        'message' => $e->getMessage(),
        // Example: "Invalid driver status transition: busy -> online"
    ], 422);
});
// You write this handler ONCE in the app.
// Every module's DomainException automatically gets caught and formatted.
// No module needs to know about HTTP status codes.app/Exceptions/Handler.php
✅ Why this design is elegant

The Domain throws a DomainException when a rule is broken. The Domain has no idea that an HTTP API exists — it's just a PHP exception. The Presentation layer's global handler catches it and converts it to the right HTTP response. Complete separation: Domain speaks business language, Presentation speaks HTTP language.

Domain / ValueObjects
AggregateRoot.php — the engine behind every aggregate

Every aggregate in your system — Driver, Merchant, future Order — extends this class. It provides the event recording mechanism that makes Domain Events possible.

💡 Note on naming

AggregateRoot.php lives in ValueObjects/ — this is a pragmatic folder choice, not a strict DDD classification. Aggregate Roots are not Value Objects. In larger projects, you'd have a separate Domain/ folder for it. Here it's grouped with shared domain primitives.

The code — every line explained

// Shared/Domain/ValueObjects/AggregateRoot.php
abstract class AggregateRoot
{
    // A private "diary" — events the aggregate has recorded but not yet fired
    // Private = no module can tamper with it from outside
    private array $pendingEvents = [];

    // Called from within Entity methods: "write this in the diary"
    // protected = only the subclass (Driver, Merchant) can call this
    protected function recordEvent(DomainEvent $event): void
    {
        $this->pendingEvents[] = $event;
    }

    // Called by Application Service AFTER saving to DB:
    // "give me all diary entries and clear the diary"
    // public = Application layer can call this
    public function pullDomainEvents(): array
    {
        $events = $this->pendingEvents;   // copy the events
        $this->pendingEvents = [];        // clear the diary
        return $events;                    // return the copy
    }
}Shared/Domain/ValueObjects/AggregateRoot.php

Why events are collected first and dispatched after the DB save

// Application Service — the correct order is CRITICAL
public function goOnline(Uuid $driverUuid, ...): void
{
    DB::transaction(function() {

        $driver = $this->repository->findByUuid($driverUuid);

        // Step 1: Entity method records the event into $pendingEvents[]
        //         but does NOT fire it yet — the DB is not saved yet!
        $driver->goOnline($location);  // internally calls: $this->recordEvent(new DriverWentOnline(...))

        // Step 2: Save to DB — if this fails, we throw and rollback
        //         The event was never fired, so no inconsistency ✅
        $this->repository->save($driver);

        // Step 3: NOW fire the events — DB is already committed
        //         pullDomainEvents() returns the events AND clears the list
        $this->eventBus->dispatchAll($driver->pullDomainEvents());
        // Listeners now run: update live map, log to analytics, etc.
    });
}

// ❌ What if you fired events BEFORE saving?
// Listeners might react to an event for data that doesn't exist in the DB yet.
// A listener queries the DB for the driver → not found → crash / stale data.
// Collect first, save, then fire = safe always.Why the order matters

How Driver and Merchant use it

// Driver extends AggregateRoot — gets recordEvent() and pullDomainEvents() for free
final class Driver extends AggregateRoot
{
    public function goOnline(DriverLocation $location): void
    {
        // ... status validation ...
        $this->status = DriverStatus::Online;
        $this->recordEvent(new DriverWentOnline($this->id, $location));
        // ↑ AggregateRoot.recordEvent() — inherited from Shared
    }
}

// Merchant extends AggregateRoot — same mechanism
final class Merchant extends AggregateRoot
{
    public function approveKyc(): void
    {
        // ... KYC validation ...
        $this->kycStatus = KycStatus::Approved;
        $this->recordEvent(new MerchantKycApproved($this->id));
        // ↑ same AggregateRoot.recordEvent() — same Shared code
    }
}Aggregates using Shared/AggregateRoot
Domain / ValueObjects
Uuid.php — the universal identity standard

Every entity in your system — Driver, Merchant, Order, Delivery — needs a unique ID. Uuid is the agreed standard for what that ID looks like and how it's generated.

The code — every part explained

// Shared/Domain/ValueObjects/Uuid.php
final class Uuid
{
    public readonly string $value;

    public function __construct(string $value)
    {
        // Validate on construction — an invalid UUID can NEVER exist
        if (! Str::isUuid($value)) {
            throw new InvalidArgumentException("Invalid UUID: {$value}");
        }
        $this->value = $value;
    }

    // Generate a brand-new UUID v4 (random)
    public static function generate(): self
    {
        return new self((string) Str::uuid());
    }

    // Compare two UUIDs — always string-to-string, case-insensitive
    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }

    // Cast to string automatically when used in string context
    public function __toString(): string
    {
        return $this->value;
    }
}Shared/Domain/ValueObjects/Uuid.php

Why UUID instead of auto-increment integers?

ProblemInteger ID (1, 2, 3...)UUID (550e8400-...)
SecurityPredictable: a user can guess that order ID 1001 exists and try /orders/1001Unguessable: UUID is random — cannot enumerate
Distributed systemsTwo servers both insert and get ID=42 — conflictGenerated before DB insert — guaranteed unique globally
API designExposes business size: "we only have 50 orders" (ID=50)Reveals nothing about business scale
Module isolationDriver ID=5, Merchant ID=5 — which 5 are you talking about?UUIDs are globally unique — no ambiguity across modules

Why the UUID class instead of plain string?

// Without Uuid class — plain string chaos
public function findDriver(string $id): ?Driver  // Could be anything!
{
    // Called as: findDriver("not-a-uuid") → no error until DB query fails
    //            findDriver("") → no error
    //            findDriver("12345") → no error
}

// With Uuid class — validated at the border
public function findDriver(Uuid $id): ?Driver  // Must be valid UUID!
{
    // Cannot reach this method with an invalid UUID
    // new Uuid("not-a-uuid") throws InvalidArgumentException immediately
}

// In your controller — validated once at the HTTP boundary:
$uuid = new Uuid($request->validated('driver_uuid'));
// From this point on, anywhere $uuid flows, it's guaranteed valid.
🔍 Note: This file uses Laravel's Str::isUuid()

The comment in your code says: "We deliberately depend on the framework helper because spinning a third-party UUID lib is overkill." This is a pragmatic decision — a tiny Laravel dependency in a Value Object is acceptable for this project. In a stricter environment, you'd use a pure PHP UUID library like ramsey/uuid.

Domain / ValueObjects
Coordinates.php — GPS as a first-class concept

GPS coordinates are used in two places: Driver locations (real-time tracking) and Address locations (pickup/dropoff). Coordinates models this concept once, correctly, with validation and the Haversine distance formula built in.

The code — every part explained

// Shared/Domain/ValueObjects/Coordinates.php
final class Coordinates
{
    public function __construct(
        public readonly float $latitude,
        public readonly float $longitude,
    ) {
        // Validate on construction — impossible to have an invalid GPS point
        if ($latitude < -90.0 || $latitude > 90.0) {
            throw new InvalidArgumentException("Latitude {$latitude} is out of range [-90, 90]");
        }
        if ($longitude < -180.0 || $longitude > 180.0) {
            throw new InvalidArgumentException("Longitude {$longitude} is out of range [-180, 180]");
        }
    }

    // Haversine formula: accurate curved-earth distance in km
    // Used by: EloquentDriverRepository.findAvailableNear() for dispatch
    public function distanceKmTo(self $other): float
    {
        $earthRadiusKm = 6371.0088;
        $lat1 = deg2rad($this->latitude);
        $lat2 = deg2rad($other->latitude);
        $deltaLat = deg2rad($other->latitude - $this->latitude);
        $deltaLng = deg2rad($other->longitude - $this->longitude);

        $a = sin($deltaLat / 2) ** 2
           + cos($lat1) * cos($lat2) * sin($deltaLng / 2) ** 2;
        $c = 2 * atan2(sqrt($a), sqrt(1 - $a));

        return $earthRadiusKm * $c;
    }
}Shared/Domain/ValueObjects/Coordinates.php

Real delivery app usage

// Scenario: Find drivers within 3km of Al Fanar Restaurant
$restaurantLocation = new Coordinates(29.3759, 47.9774); // Kuwait City
$driverLocation     = new Coordinates(29.3812, 47.9801); // Ahmed's position

$distance = $restaurantLocation->distanceKmTo($driverLocation);
// Returns: 0.67 km — Ahmed is only 670 metres away ✅
// Used in: EloquentDriverRepository::findAvailableNear()

// Why not just compare raw floats?
// Because the Earth is a sphere. The straight-line distance between
// lat/lng values is NOT the real-world distance — the Haversine formula
// accounts for Earth's curvature. This matters for city-scale navigation.

// Why validate range?
$bad = new Coordinates(200.0, 47.97);
// throws: "Latitude 200.0 is out of range [-90, 90]"
// Without this, a mobile app bug could send lat=200 and it would silently
// corrupt the driver's location in the database.Coordinates in dispatch
Domain / ValueObjects
Address.php — a structured address with GPS

An address is not just a string. It has multiple fields, and in a delivery app it also has GPS coordinates so drivers can navigate to it. Address bundles all of this into one safe, structured object.

The code

// Shared/Domain/ValueObjects/Address.php
final class Address
{
    public function __construct(
        public readonly string      $line1,
        public readonly ?string     $line2,       // optional (apartment number)
        public readonly string      $city,
        public readonly ?string     $state,       // optional (not all countries use this)
        public readonly ?string     $postalCode,  // optional (Kuwait uses block/area system)
        public readonly string      $country,     // ISO code: 'KW', 'AE', 'SA'
        public readonly Coordinates $coordinates, // GPS embedded! ← uses Shared/Coordinates
        public readonly ?string     $notes = null, // "Blue gate on the left"
    ) {}

    // Helper: build a readable string from all parts
    public function fullAddress(): string
    {
        return trim(implode(', ', array_filter([
            $this->line1, $this->line2, $this->city,
            $this->state, $this->postalCode, $this->country,
        ])));
        // Example: "Arabian Gulf Street, Kuwait City, KW"
    }
}Shared/Domain/ValueObjects/Address.php
📦 Why not just store addresses as a string?

If you store "Arabian Gulf Street, Kuwait City" as a plain string, you can't: calculate the distance from a driver to the pickup point (no GPS), filter deliveries by city (no separate city field), display it on a map (no lat/lng). As a Value Object with embedded Coordinates, all of this is automatic.

Where Address is used in your project

Merchant default address
Merchant entity stores its business address — used as default pickup location for deliveries.
Delivery pickup address
The Delivery module (future) stores a pickup Address — the coordinates guide the driver to the restaurant.
Delivery dropoff address
The customer's delivery address — with embedded GPS for last-mile navigation.
Distance calculation
$pickup->coordinates->distanceKmTo($driverLocation) — uses both Address and Coordinates.
Domain / ValueObjects
Money.php — safe financial arithmetic

Money is the most dangerous primitive in any financial application. Storing it as float causes real money bugs in production. Money solves this by using integer minor units (cents) for all storage and math.

The float problem — a real bug

// ❌ THE FLOATING POINT TRAP — this is a real problem in production
$deliveryFee = 1.10;
$platformFee = 0.20;
$total = $deliveryFee + $platformFee;

var_dump($total); // float(1.2999999999999998) ❌ NOT 1.30!

// This is not PHP being bad — ALL computers have this problem.
// Floats cannot represent 1.30 exactly in binary.
// In a payment system: round(1.2999...) = 1.30 sometimes, 1.29 sometimes.
// Multiply by 10,000 orders → significant money discrepancy.

// ✅ THE MONEY SOLUTION — integer minor units (fils/cents)
$deliveryFee = new Money(110, 'KWD');  // 110 fils = KWD 1.10
$platformFee = new Money(20, 'KWD');   // 20 fils = KWD 0.20
$total = $deliveryFee->add($platformFee); // 130 fils = KWD 1.30 exactly ✅
// Integer addition: 110 + 20 = 130. Always exact. No floating point.

The Money API — all the operations

// Shared/Domain/ValueObjects/Money.php — key methods

// Create from major units (human-friendly input)
$fee = Money::fromMajor(12.50, 'KWD');   // 12.50 KWD → stored as 1250 fils
$tip = Money::fromMajor(2.00, 'KWD');   // 2.00 KWD → stored as 200 fils

// Arithmetic — always returns a NEW Money object (immutable)
$total    = $fee->add($tip);           // 1450 fils = KWD 14.50
$discount = $total->multiply(0.9);   // 1305 fils = KWD 13.05 (10% off)
$refund   = $total->subtract($tip);   // 1250 fils = KWD 12.50

// Checks
$discount->isNegative(); // false
Money::zero('KWD')->isZero();  // true

// Currency protection — prevents silent bugs
$kwdMoney = new Money(1000, 'KWD');
$aedMoney = new Money(1000, 'AED');
$kwdMoney->add($aedMoney);
// throws: "Currency mismatch: KWD vs AED"
// Cannot accidentally mix Kuwaiti Dinars with UAE Dirhams

// Display
echo $total->asMajor();  // 14.5 (float for display only)
$total->toArray();       // ['amount' => 1450, 'currency' => 'KWD']Money API usage

Where Money is used in your delivery app

Merchant credit limit
$merchant->creditLimit is a Money object — prevents mixing currencies for multi-country merchants.
Delivery fee
The Delivery module calculates fee as Money — base fee + distance surcharge, always exact.
Driver earnings
Driver payout per delivery stored as Money — summed across many deliveries without drift.
Database storage
Repository stores $money->minorUnits (integer) and $money->currency (string) separately. Reconstructed with new Money($row->amount, $row->currency).
Domain / ValueObjects
Email.php & PhoneNumber.php — validated contact data

Both Email and PhoneNumber follow the same pattern: validate once at construction, normalise the format, and become impossible to use incorrectly anywhere in the system.

Email.php

// Shared/Domain/ValueObjects/Email.php
final class Email
{
    public readonly string $value;

    public function __construct(string $value)
    {
        // Normalise: trim whitespace + lowercase — "Ahmed@GMAIL.COM " → "ahmed@gmail.com"
        $trimmed = strtolower(trim($value));

        // PHP's built-in validator
        if (filter_var($trimmed, FILTER_VALIDATE_EMAIL) === false) {
            throw new InvalidArgumentException("Invalid email: {$value}");
        }
        $this->value = $trimmed;
    }
}

// Usage in Merchant registration:
$email = new Email('Ahmed@AlFanar.KW'); // ✅ normalised to "ahmed@alfanar.kw"
$bad   = new Email('not-an-email');      // ❌ throws immediately

// Two merchants with same email — are they equal?
$a = new Email('Ahmed@Example.com');
$b = new Email('ahmed@example.com');
$a->equals($b); // true ✅ — normalised before comparisonEmail.php

PhoneNumber.php

// Shared/Domain/ValueObjects/PhoneNumber.php
final class PhoneNumber
{
    public readonly string $value;

    public function __construct(string $value)
    {
        // Strip spaces, dashes, parentheses: "+965 (2223) 3456" → "+96522233456"
        $cleaned = preg_replace('/[\s()-]/', '', $value) ?? '';

        // E.164 format: optional +, then 7-15 digits starting with 1-9
        if (! preg_match('/^\+?[1-9][0-9]{6,14}$/', $cleaned)) {
            throw new InvalidArgumentException("Invalid phone number: {$value}");
        }

        // Always add + prefix for E.164 standard
        $this->value = str_starts_with($cleaned, '+') ? $cleaned : '+'.$cleaned;
    }
}

// Examples:
new PhoneNumber('+96522233456');   // ✅ Kuwait number
new PhoneNumber('965 2223 3456');  // ✅ stripped + normalised → "+96522233456"
new PhoneNumber('123');           // ❌ too short → InvalidArgumentException
new PhoneNumber('abc');           // ❌ not digits → InvalidArgumentException
PhoneNumber.php
✅ The "validate once, trust everywhere" principle

Once an Email or PhoneNumber object exists, it is guaranteed valid and normalised. You never need to validate it again — in Repositories, Services, DTOs, or any other layer. The type system becomes your validator. This is one of the key powers of Value Objects.

Infrastructure
LaravelDomainEventBus.php — connecting Domain to Laravel

This is the adapter that bridges your framework-agnostic Domain Events to Laravel's actual event system. It's one of the most elegant examples of the Dependency Inversion Principle in your entire project.

The code

// Shared/Infrastructure/Bus/LaravelDomainEventBus.php
final class LaravelDomainEventBus implements DomainEventBus
{
    // Wraps Laravel's Dispatcher — the engine behind event() and Event::dispatch()
    public function __construct(private readonly Dispatcher $dispatcher) {}

    // Dispatches ONE event to Laravel's event system
    public function dispatch(DomainEvent $event): void
    {
        $this->dispatcher->dispatch($event);
        // Laravel calls all Listeners registered for this event class
    }

    // Dispatches ALL events collected from an aggregate after DB commit
    public function dispatchAll(iterable $events): void
    {
        foreach ($events as $event) {
            $this->dispatcher->dispatch($event);
        }
    }
}Shared/Infrastructure/Bus/LaravelDomainEventBus.php

The three-layer bridge — visualised

AppService         DomainEventBus      LaravelDomainEventBus   Laravel Dispatcher   Listener
    │                    │                       │                          │                     │
    │  dispatchAll(      │                       │                          │                     │
    │   [DriverWentOnline│                       │                          │                     │
    │   ])               │                       │                          │                     │
    │ ─────────────────→ │  (interface contract) │                          │                     │
    │                    │ ─────────────────────→│                          │                     │
    │                    │                       │  dispatcher->dispatch()  │                     │
    │                    │                       │ ────────────────────────→│                     │
    │                    │                       │                          │  call listeners     │
    │                    │                       │                          │ ───────────────────→│
    │                    │                       │                          │                     │  send notification
    │                    │                       │                          │                     │  update analytics
    │                    │                       │                          │                     │  store GPS history

The Application Service (AppService) only knows about the DomainEventBus interface. It never knows that Laravel is involved. If you swapped Laravel for Symfony tomorrow, you'd only rewrite LaravelDomainEventBus — the Application Service stays identical.

Why this is in Infrastructure and not Domain

Because it uses Illuminate\Contracts\Events\Dispatcher — a Laravel class. The Domain must never import from Laravel. Infrastructure is specifically the layer that is allowed to use framework classes. The contract (DomainEventBus) lives in Domain. The implementation (LaravelDomainEventBus) lives in Infrastructure. They're connected only by the interface.

Infrastructure
SharedKernelServiceProvider.php — the wiring

The Service Provider is where Laravel is told: "When anyone asks for DomainEventBus, give them LaravelDomainEventBus." It's the one place where the Domain contract and its Infrastructure implementation are connected.

The code

// Shared/Infrastructure/Providers/SharedKernelServiceProvider.php
final class SharedKernelServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // singleton: create ONE instance and reuse it for the whole request
        // (vs bind: creates a new instance every time)
        $this->app->singleton(
            DomainEventBus::class,           // The interface (Domain layer)
            LaravelDomainEventBus::class,    // The implementation (Infrastructure layer)
        );
        // Now anywhere in the app:
        // new ApproveMerchantKycService($repo, $eventBus)
        // Laravel injects LaravelDomainEventBus as $eventBus automatically
    }
    // No boot() method — nothing to do after registration
}SharedKernelServiceProvider.php

Why singleton instead of bind?

// bind: creates new LaravelDomainEventBus every time DomainEventBus is requested
// singleton: creates it ONCE and reuses the same instance

// For an event bus, singleton is correct because:
// - Laravel's Dispatcher is stateful (it has listener registrations)
// - Creating a fresh Dispatcher for each service call would lose all listener bindings
// - One shared instance = all services use the same dispatcher = listeners work ✅

// Other typical singletons:
// - Database connections (expensive to create)
// - Cache clients
// - External API clients

How all providers boot together

// bootstrap/providers.php (Laravel 11) or config/app.php (Laravel 10)
return [
    // 1. Shared Kernel — MUST be first, other providers depend on it
    SharedKernelServiceProvider::class,   // registers DomainEventBus

    // 2. Module providers — register their own repository bindings
    DriverServiceProvider::class,         // registers DriverRepository binding, routes
    MerchantServiceProvider::class,       // registers MerchantRepository binding, routes
];
// Order matters: SharedKernel before modules,
// because modules' services type-hint DomainEventBus which must be registered first.bootstrap/providers.php
Application Layer
Shared/Application/ — empty but intentional

The Shared/Application/ folder exists even though it's empty. This is intentional design, not an oversight. It reserves space for shared use-case helpers that will grow as the project scales.

Why keep an empty folder?

The folder communicates the architecture to your entire team. When a new developer joins and sees Shared/Application/, they immediately understand: "If I need to build something that belongs to Application layer and is used by multiple modules, it goes here." Without the folder, people put shared application-layer code in random places.

What goes here as the project grows?

Pagination helpers

A shared PaginationQuery object with $page, $perPage fields. Used by every list endpoint: ListNearbyDriversQuery, ListPendingMerchantsQuery, future ListOrdersQuery.

final class PaginationQuery {
    public function __construct(
        public readonly int $page = 1,
        public readonly int $perPage = 20,
    ) {}
}
Paginated result wrapper

A shared PaginatedResult DTO: wraps any list result with $items, $total, $page, $lastPage. Every list Handler returns this standard shape.

Sort / filter base classes

A SortOrder enum (Asc/Desc) used in Queries. Avoids duplicating this in every module's Query class.

Shared use-case interface

A CommandHandler interface enforcing that all Handlers implement handle($command). Useful if you adopt a command bus that auto-discovers handlers.

Walkthroughs
Full event flow — from Entity to Listener

Let's trace a single business action — admin approves a merchant's KYC — through every file in the Shared Kernel and back out to the real world.

1
HTTP Request arrives
POST /api/v1/merchants/{uuid}/kyc/approve hits the server. Laravel authenticates the admin token, routes to MerchantController::approveKyc().
2
Controller creates a Command and calls Handler
new ApproveMerchantKycCommand(merchantUuid: new Uuid($uuid))
— uses Shared/Uuid to validate and wrap the UUID.
Then: $this->kycHandler->handle($command)
3
Service starts a DB transaction
ApproveMerchantKycService opens DB::transaction(). Everything inside either all succeeds or all rolls back.
4
Repository loads the Merchant Entity
$merchant = $this->repository->findByUuid($uuid). The Repository hydrates a Merchant object (which extends Shared/AggregateRoot) from the database row.
5
Entity method called — Domain enforces the rule
$merchant->approveKyc() is called. Inside:
— Checks: $this->kycStatus !== KycStatus::Submitted → if so, throws InvalidKycTransitionException (which extends Shared/DomainException).
— If OK: sets $this->kycStatus = KycStatus::Approved
— Calls $this->recordEvent(new MerchantKycApproved($this->id))
recordEvent() is inherited from Shared/AggregateRoot — adds event to $pendingEvents[]
6
Repository saves the updated Merchant to DB
$this->repository->save($merchant) — Eloquent writes the new kycStatus = 'approved' to the database. Transaction is still open.
7
Transaction commits — DB is consistent
The DB::transaction closure returns successfully. MySQL commits the row. The Merchant is now Approved in the database.
8
Events are pulled and dispatched via Shared EventBus
$this->eventBus->dispatchAll($merchant->pullDomainEvents())
pullDomainEvents() from Shared/AggregateRoot: returns [MerchantKycApproved] and clears the list.
dispatchAll() from Shared/DomainEventBus contract — implemented by Shared/LaravelDomainEventBus.
— Calls $this->dispatcher->dispatch($event) — hands off to Laravel.
9
Laravel calls all Listeners
Laravel's event dispatcher calls every Listener registered for MerchantKycApproved:
SendKycApprovedEmailListener — sends welcome email to merchant.
UnlockMerchantDashboardListener — enables frontend features.
LogKycApprovalListener — writes to audit log.
Each Listener runs independently. Adding a new reaction never requires changing the Entity or Service.
🔑 Every Shared file touched in this flow

Uuid — wrapping the merchant UUID from the URL. AggregateRootrecordEvent() and pullDomainEvents(). DomainEventMerchantKycApproved extends it. DomainEventBus — interface the Service calls. LaravelDomainEventBus — actual dispatch implementation. DomainException — if KYC wasn't Submitted, thrown and caught globally. That's 6 of the 13 files in Shared — all in one business operation.

Walkthroughs
Full request lifecycle — showing every Shared touch point
POST /api/v1/drivers/me/online  { lat: 29.37, lng: 47.97, heading: 90 }

1. Laravel Router
   └── Matches route → DriverController::goOnline()
   └── auth:sanctum middleware: validates Bearer token

2. DriverController::goOnline()
   └── $request->validate(['lat' => 'numeric|between:-90,90', ...])
   └── $driverUuid = new Uuid($self->uuid)         ← SHARED: Uuid validates
   └── $coordinates = new Coordinates(29.37, 47.97) ← SHARED: Coordinates validates range
   └── new GoOnlineCommand($driverUuid, $coordinates, ...)
   └── $this->onlineHandler->handle($command)

3. GoOnlineHandler
   └── $this->service->goOnline($cmd->driverUuid, $cmd->location, ...)

4. UpdateDriverStatusService::goOnline()
   └── DB::transaction(function() {
         $driver = $repo->findByUuid($driverUuid)
         
         5. Driver::goOnline(DriverLocation)
            └── $this->status->canGoOnline() → checks DriverStatus enum
            └── if not allowed → throw InvalidDriverStatusTransition
                                           ← SHARED: DomainException subclass
                                           → caught by global handler → HTTP 422
            └── $this->status = DriverStatus::Online
            └── $this->lastLocation = $location
            └── $this->recordEvent(new DriverWentOnline($this->id, $location))
                            ← SHARED: AggregateRoot::recordEvent()
                            ← DomainEvent: DriverWentOnline extends DomainEvent
         
         6. EloquentDriverRepository::save($driver)
            └── Flattens Driver entity → DriverModel columns
            └── $model->save() → MySQL UPDATE drivers SET status='online', lat=...
         
      }) ← transaction commits
   
7. $this->eventBus->dispatchAll($driver->pullDomainEvents())
               ← SHARED: AggregateRoot::pullDomainEvents()
               ← SHARED: DomainEventBus contract
               ← SHARED: LaravelDomainEventBus::dispatchAll()
   └── dispatcher->dispatch(DriverWentOnline)
   └── Listener: UpdateLiveMapListener → broadcasts driver position to frontend
   └── Listener: LogDriverActivityListener → analytics

8. DriverController
   └── return response()->json(['data' => ['status' => 'online']], 202)
Walkthroughs
Order creation — showing Shared across multiple modules

This example shows how Shared Value Objects flow through a cross-module scenario: a merchant creates a delivery order, and a driver gets assigned.

// Hypothetical Delivery module — uses Shared types from all over

// POST /api/v1/orders
{
  "merchant_uuid": "aaa-111",
  "pickup_address": { "line1": "Al Fanar Mall", "city": "Kuwait City",
                      "country": "KW", "lat": 29.37, "lng": 47.97 },
  "dropoff_address": { "line1": "Block 5, Salmiya", "city": "Salmiya",
                       "country": "KW", "lat": 29.33, "lng": 48.08 },
  "delivery_fee": { "amount": 1250, "currency": "KWD" }  // KWD 12.50
}

// Delivery module's Order entity — uses Shared for everything
final class Order extends AggregateRoot  // ← SHARED
{
    private function __construct(
        public readonly Uuid    $id,              // ← SHARED
        public readonly Uuid    $merchantId,      // ← SHARED
        public readonly Address $pickupAddress,   // ← SHARED
        public readonly Address $dropoffAddress,  // ← SHARED
        public readonly Money   $deliveryFee,     // ← SHARED
        private          OrderStatus $status,    // ← Delivery module's own VO
        private         ?Uuid    $assignedDriverId, // ← SHARED (just the ID!)
    ) {}

    public static function create(Uuid $merchantId, Address $pickup, ...): self
    {
        // Check: is distance reasonable? Uses Shared Coordinates
        $distance = $pickup->coordinates->distanceKmTo($dropoff->coordinates);
        if ($distance > 50) {
            throw new DeliveryAreaTooLargeException(/* extends DomainException ← SHARED */);
        }

        $order = new self(id: Uuid::generate(), ...); // ← SHARED Uuid
        $order->recordEvent(new OrderCreated($order->id)); // ← SHARED AggregateRoot
        return $order;
    }

    public function assignDriver(Uuid $driverUuid): void
    {
        $this->assignedDriverId = $driverUuid;  // just the UUID ← SHARED
        $this->status = OrderStatus::Assigned;
        $this->recordEvent(new DriverAssigned($this->id, $driverUuid)); // ← SHARED
    }
}
// Key point: Order references Driver only by Uuid — never imports Driver entity
// Modules stay isolated. Shared Uuid is the bridge between them.Order entity — uses Shared throughout
Wisdom
When NOT to put things in Shared

The most dangerous mistake in DDD is over-sharing. If Shared grows too large, every module becomes coupled to it and you've created a "distributed monolith." Here are the rules for what does NOT belong in Shared.

ThingBelongs in Shared?Why / Why not
KycStatus❌ NoKYC is a Merchant concept only. If you put it in Shared, Drivers are coupled to a concept they have nothing to do with.
DriverStatus❌ NoBelongs to Driver module only. No other module needs to import it.
RegisterMerchantService❌ NeverApplication Services contain use-case logic. They belong to their module.
Coordinates✅ YesBoth Driver locations and Address locations use GPS. Genuinely shared.
MerchantRepository❌ NoThe Merchant repository belongs to the Merchant bounded context, not shared infrastructure.
Money✅ YesMerchant billing, Driver earnings, Delivery fees — all monetary. Genuinely shared.
DeliveryFeeCalculator❌ NoContains business logic about delivery pricing. Belongs to Delivery module.
Uuid✅ YesEvery entity in every module needs identifiers. Genuinely universal.
❌ The Shared Kernel trap — "it might be useful"

The most common mistake is putting things in Shared because "another module might need this someday." Only put something in Shared when two or more modules need it RIGHT NOW. Premature sharing creates coupling. A class that starts in one module can always be moved to Shared later when a second module genuinely needs it — moving is easy, but removing from Shared is painful because every module that imported it will break.

The two questions to ask before adding to Shared

1. Do at least TWO modules currently need this?

If only one module needs it → put it in that module.

2. Does it contain module-specific business logic?

If it knows about KYC, delivery fees, or dispatch rules → it is NOT shared infrastructure, it's a domain concept. Put it in the right module.

Wisdom
Common mistakes with Shared Kernel
Mistake 1 — Making Shared too large
// ❌ WRONG: Everything ends up in Shared
Shared/Domain/
├── ValueObjects/
│   ├── KycStatus.php          ← Merchant only! Not shared!
│   ├── DriverStatus.php       ← Driver only! Not shared!
│   ├── DeliveryStatus.php     ← Delivery only! Not shared!
├── Services/
│   ├── PricingService.php     ← Business logic! Wrong layer!
│   └── DispatchService.php    ← Business logic! Wrong layer!

Impact: All modules become coupled to Shared. Changing any file in Shared potentially breaks all modules. You've lost the isolation DDD was supposed to provide.

Mistake 2 — Importing module types into Shared
// ❌ WRONG: Shared imports from a module
namespace DeliveryApp\Shared\Domain;
use DeliveryApp\Driver\Domain\Entities\Driver;  // ← NEVER!
use DeliveryApp\Merchant\Domain\Entities\Merchant; // ← NEVER!

// Shared must have ZERO imports from any module.
// Modules depend on Shared. Shared depends on nothing.
Mistake 3 — Firing events before the DB commit
// ❌ WRONG: Events fired inside the transaction
DB::transaction(function() {
    $merchant->approveKyc();
    $this->eventBus->dispatchAll($merchant->pullDomainEvents()); // ← too early!
    $this->repository->save($merchant);  // ← if this fails, events already fired ❌
});

// ✅ CORRECT: Events fired AFTER the transaction closes
DB::transaction(function() {
    $merchant->approveKyc();
    $this->repository->save($merchant); // ← save first
}); // ← transaction commits here
$this->eventBus->dispatchAll($merchant->pullDomainEvents()); // ← now safe ✅
Mistake 4 — Storing Money as float in the database
// ❌ WRONG: Saving float to DB
$model->delivery_fee = $money->asMajor();  // stores 12.50 → might be 12.4999999 ❌

// ✅ CORRECT: Save integer minor units
$model->delivery_fee_amount   = $money->minorUnits;   // 1250 — exact integer ✅
$model->delivery_fee_currency = $money->currency;     // 'KWD'

// Reconstruct on load:
$money = new Money($model->delivery_fee_amount, $model->delivery_fee_currency);
Mistake 5 — Catching DomainException in Services
// ❌ WRONG: Service swallows the business error
try {
    $merchant->approveKyc();
} catch (DomainException $e) {
    return false; // ← hiding the error! Caller doesn't know what went wrong
}

// ✅ CORRECT: Let it bubble up to the global handler
$merchant->approveKyc();
// If it throws → bubbles to app/Exceptions/Handler.php → HTTP 422 with clear message
// The Domain speaks. The Presentation layer translates. Don't intercept in between.
Wisdom
Testing strategies for Shared Kernel

Shared code is used by every module. A bug here breaks everything. Here's how to test each piece, with real examples you can run right now.

Testing Value Objects — fast, no DB needed

// tests/Unit/Shared/ValueObjects/UuidTest.php
it('accepts valid UUID v4', function() {
    $uuid = new Uuid('550e8400-e29b-41d4-a716-446655440000');
    expect($uuid->value)->toBe('550e8400-e29b-41d4-a716-446655440000');
});

it('rejects invalid UUID', function() {
    expect(fn() => new Uuid('not-a-uuid'))
        ->toThrow(InvalidArgumentException::class);
});

it('generates unique UUIDs', function() {
    $a = Uuid::generate();
    $b = Uuid::generate();
    expect($a->equals($b))->toBeFalse();
});

// tests/Unit/Shared/ValueObjects/CoordinatesTest.php
it('calculates Haversine distance correctly', function() {
    $kuwaiti   = new Coordinates(29.3759, 47.9774);
    $salmiya   = new Coordinates(29.3326, 48.0785);
    $distance  = $kuwaiti->distanceKmTo($salmiya);
    expect($distance)->toBeGreaterThan(9)->toBeLessThan(12);
    // ~10.5 km — verified against Google Maps
});

it('rejects latitude out of range', function() {
    expect(fn() => new Coordinates(91.0, 0.0))
        ->toThrow(InvalidArgumentException::class, 'out of range');
});

// tests/Unit/Shared/ValueObjects/MoneyTest.php
it('adds money without float drift', function() {
    $a     = Money::fromMajor(1.10, 'KWD');
    $b     = Money::fromMajor(0.20, 'KWD');
    $total = $a->add($b);
    expect($total->minorUnits)->toBe(130);   // exact ✅
    expect($total->asMajor())->toBe(1.30);   // exact ✅
});

it('throws on currency mismatch', function() {
    $kwd = new Money(1000, 'KWD');
    $aed = new Money(1000, 'AED');
    expect(fn() => $kwd->add($aed))
        ->toThrow(InvalidArgumentException::class, 'Currency mismatch');
});Value Object unit tests (PestPHP)

Testing AggregateRoot event recording

// tests/Unit/Shared/ValueObjects/AggregateRootTest.php
it('records and pulls domain events', function() {
    // Create a concrete Aggregate for testing
    $aggregate = new class extends AggregateRoot {
        public function doSomething(): void {
            $this->recordEvent(new class extends DomainEvent {
                public function eventName(): string { return 'test.happened'; }
                public function toArray(): array { return []; }
            });
        }
    };

    $aggregate->doSomething();
    $events = $aggregate->pullDomainEvents();

    expect($events)->toHaveCount(1);
    expect($events[0]->eventName())->toBe('test.happened');

    // Pull again — list should be cleared
    expect($aggregate->pullDomainEvents())->toBeEmpty();
});

it('sets occurredAt automatically', function() {
    $before = new DateTimeImmutable();
    $event  = new class extends DomainEvent {
        public function eventName(): string { return 'x'; }
        public function toArray(): array { return []; }
    };
    expect($event->occurredAt)->toBeGreaterThanOrEqual($before);
});AggregateRoot tests

Testing the LaravelDomainEventBus

// tests/Unit/Shared/Infrastructure/LaravelDomainEventBusTest.php
it('dispatches event to Laravel dispatcher', function() {
    $dispatcher = Mockery::mock(Dispatcher::class);
    $bus        = new LaravelDomainEventBus($dispatcher);
    $event      = new class extends DomainEvent {
        public function eventName(): string { return 'test'; }
        public function toArray(): array { return []; }
    };

    $dispatcher->shouldReceive('dispatch')->once()->with($event);
    $bus->dispatch($event);
    // No real Laravel needed — Mockery replaces the Dispatcher ✅
});Infrastructure test with mock
Wisdom
Scaling, teams, and microservices

The Shared Kernel pattern is also how companies prepare for microservice extraction. Here's how it works at scale.

Multiple teams — who owns what?

WhatWho owns itChange process
Driver moduleDriver teamTeam decides alone — their bounded context
Merchant moduleMerchant teamTeam decides alone — their bounded context
Shared kernelAll teams jointlyAny change requires agreement from ALL teams who depend on it — because a breaking change breaks everyone

Shared Kernel in a future microservice world

// Today: monolith — all modules in one codebase
src/
├── Shared/       ← shared kernel, one codebase
├── Driver/       ← driver module
├── Merchant/     ← merchant module
└── Delivery/     ← delivery module

// Future: microservices — Shared becomes a library
composer require deliveryapp/shared-kernel   ← published as package

// driver-service/ (separate repo, separate deploy)
require deliveryapp/shared-kernel   ← uses Uuid, Coordinates, DomainEvent etc.

// merchant-service/ (separate repo, separate deploy)
require deliveryapp/shared-kernel   ← same classes, consistent types

// This works because:
// 1. Shared has NO dependencies on modules
// 2. Shared contains ONLY structural/technical code
// 3. Money, Uuid, Coordinates mean the same thing across all services

The warning about Shared in microservices

⚠️ Shared Kernel coupling in distributed systems

In a microservice world, changing Money (e.g. adding a new constructor parameter) requires updating and redeploying ALL services that depend on the shared kernel package simultaneously. This is called temporal coupling. The solution: keep the Shared Kernel small and change it rarely. Value Objects like Uuid, Money, Coordinates are stable and rarely need to change — that's exactly why they belong in Shared.

Final summary — every Shared file and its purpose

FileLayerPurposeUsed by
DomainEventBusContractInterface for firing eventsEvery Application Service
DomainEventDomainBase class for all eventsEvery module's events
DomainExceptionDomainMarker for business rule violations → HTTP 422Every module's exceptions
AggregateRootDomainEvent recording and pullingDriver, Merchant, Order aggregates
UuidDomainValidated entity identity standardEvery entity, every command, every repository
CoordinatesDomainGPS + Haversine distanceDriverLocation, Address, dispatch queries
AddressDomainStructured address with embedded GPSMerchant defaults, Delivery pickup/dropoff
MoneyDomainFloat-safe currency mathMerchant credit, delivery fees, driver pay
EmailDomainValidated, normalised emailMerchant contact, Driver contact, User
PhoneNumberDomainE.164 phone normalisationMerchant contact, Driver contact, Customer
LaravelDomainEventBusInfrastructureBridges DomainEventBus → Laravel dispatcherLaravel DI container (injected by provider)
SharedKernelServiceProviderInfrastructureRegisters DomainEventBus → LaravelDomainEventBusbootstrap/providers.php
Shared/Application/ApplicationReserved for shared pagination, sorting helpersFuture Queries across all modules
The one thing to remember

The Shared Kernel is not about sharing code to avoid typing. It's about establishing a common language — a set of concepts that mean exactly the same thing everywhere in your system. A Uuid is always a Uuid. A Money value is always safe. A DomainEvent always carries a timestamp and a name.

When you follow this pattern, the modules of your system can grow independently — but they still speak the same language. That's the whole point of DDD.