Skip to content

Architecture

The design principles behind all Jardis packages: Hexagonal Architecture, Closure-Orchestrator Pattern and the dependency direction.

Overview

Jardis is not a monolithic web framework: no framework that owns the entire application, no mandated global service container, no enforced lifecycle. Instead, Jardis provides specialized packages that compose into DDD applications. Every package follows the same architectural principles, described here.

For HTTP delivery there is a dedicated, deliberately small delivery layer: App (jardiscore/app) provides a router behind its own RouterInterface, a PSR-15 middleware pipeline, and a canonical DomainResponse→PSR-7 envelope mapper. The decoupling remains decisive: no generated domain imports this layer. It is optional and swappable at any time for Symfony, Laravel, or your own delivery.

Design Principles

Separation of Concerns

Different responsibilities → different classes. Cross-cutting concerns (logging, caching, auth) are solved via decorator closures, not inheritance.

Composition over Inheritance

Interfaces and constructor injection instead of abstract classes. No traits. abstract only for exception hierarchies. static only for value object named constructors (Email::from()).

Data-Behavior Separation

Data structures (DTOs, entities, value objects) hold data. Services operate on data. Persistence runs externally via repositories. This is reflected in the package structure: Data/ for data, Handler/ for behavior.

Explicit Dependencies

All dependencies are injected via the constructor. Infrastructure sits behind interfaces. No implicit access to state outside the own scope.

Hexagonal Architecture

All Jardis packages are organized in four categories. The central rule: Dependencies always point inward. Outer categories know about inner categories, never the reverse.

┌─────────────────────────────────────────┐
│  Tools (JardisTools\*)                  │  Development time
├─────────────────────────────────────────┤
│  Adapter (JardisAdapter\*)              │  Infrastructure
├─────────────────────────────────────────┤
│  Support (JardisSupport\*)              │  Application / Cross-cutting
├─────────────────────────────────────────┤
│  Core (JardisCore\*)                    │  Domain
└─────────────────────────────────────────┘
         ↑  Dependencies point inward

The Four Categories

Core (JardisCore\*) — The domain layer. Contains Kernel and App: the immutable DomainKernel Koffer for generated domains and the HTTP-delivery layer (router, PSR-15 pipeline, envelope mapper) around it.

Support (JardisSupport\*) — Application layer and cross-cutting concerns. Packages like DbQuery, Repository, Validation, Data, Workflow. Also the Contract namespace with all interfaces.

Adapter (JardisAdapter\*) — Infrastructure layer. Concrete implementations: Cache, Database Connection, Logger, EventDispatcher, HTTP, Mailer, Messaging, Filesystem.

Tools (JardisTools\*) — Development-time utilities. DbSchema for schema analysis, Builder for code generation. Not needed at runtime.

Dependency Direction

JardisTools → JardisAdapter → JardisSupport → JardisCore
                   |               |              |
         JardisSupport\Contract <──<──────────<──<
  • Core never imports adapter code
  • Adapters implement interfaces from JardisSupport\Contract
  • Support packages are independent of each other
  • Tools may import anything: they only run at development time

Interfaces Over Implementations

All Jardis packages program against interfaces from jardissupport/contracts. This decouples Core from concrete infrastructure:

php
// Core only knows the interface
use JardisSupport\Contract\Cache\CacheInterface;

// Adapter provides the implementation
use JardisAdapter\Cache\Cache;

// The kernel packer wires both together
$kernel = new DomainKernel(cache: new Cache([...]));

Swap the cache (Redis → Memcached): Core and Support remain unchanged.

Domain and BoundedContext Encapsulation

Hexagonal Architecture doesn't stop at the package level: it continues into the generated DDD code. Every domain is fully encapsulated. Access is only possible through the API.

Domain: Ecommerce
BoundedContext: Sales
command()
createOrder() · addItem() · removeItem()
query()
getOrder() · listOrders()
event()
OrderCreated · ItemAdded · ItemRemoved
BoundedContext: Inventory
command()
reserveStock() · releaseStock()
query()
getStock() · listWarehouses()
event()
StockReserved · StockReleased

PSR Standards

Jardis consistently implements and uses PSR standards:

StandardJardis PackageDescription
PSR-3LoggerLogging interface
PSR-7HTTPHTTP messages
PSR-11FactoryContainer interface
PSR-12allCoding standard
PSR-14EventDispatcherEvent system
PSR-16CacheSimple cache
PSR-17HTTPHTTP factories
PSR-18HTTPHTTP client

Versioning — ClassVersion

Jardis solves one of the hardest problems in long-lived software systems: How do you evolve a system with hundreds of classes without every change requiring a coordinated big-bang release?

The answer is ClassVersion, a versioning strategy at the architecture level, built into the kernel.

The Principle

The version is not set per class, but per invocation context. A generated {Domain}Context receives a version, and every handle() call within that context resolves classes automatically in the matching version, with a fallback chain to older versions or the base class:

handle('Payment', 'v2')        → App\Domain\v2\Payment       (v2 exists)
handle('Invoice', 'v2')        → App\Domain\v1\Invoice        (no v2 → fallback v1)
handle('Customer', 'v2')       → App\Domain\Customer           (no v2/v1 → base class)

The calling code knows nothing about versions. It works with logical class names. The version is a property of the context, not of individual classes.

Why This Matters

TraditionalWith ClassVersion
Breaking change → migrate all clients simultaneouslyOld and new versions coexist at runtime
Feature flags and if/else chainsArchitecture handles the routing decision
Rename class + update hundreds of importsClass name stays the same, namespace injection handles the rest
Big-bang release after weeksMigrate class by class, deploy sprint by sprint

Runtime Coexistence

Different API versions, different tenants, or different migration phases can run simultaneously in the same process:

php
// v1 endpoint for legacy clients
return (new PlaceOrderContext($this->kernel(), $data, version: 'v1'))();

// v2 endpoint for new clients
return (new PlaceOrderContext($this->kernel(), $data, version: 'v2'))();

The same BoundedContext, but internally the validator, repository, and hydrator are each resolved in the matching version.

Builder Integration

In Jardis projects, the builder generates code as base classes. Custom code lives in v2/ subdirectories and is not overwritten on regeneration:

src/Domain/Order/
├── HydrateOrder.php        ← generated by builder (regeneratable)
├── v2/
│   └── HydrateOrder.php    ← custom override (protected)
└── ValidateOrder.php       ← generated by builder (no override needed)

ClassVersion automatically resolves v2\HydrateOrder because it exists. ValidateOrder without a v2 override is used as the base class. Generated and manual code coexist, without conflict.

Closure-Orchestrator Pattern

The consistent structural principle across all Jardis packages. No abstract classes, no traits, no service locator, instead closures and composition.

Closure = Atomic Unit

A closure is a class with a single public entry point: __invoke(). The class name describes what it does. Input → Processing → Output. No additional public methods.

php
final class BuildKey
{
    public function __invoke(string $prefix, string $path): string
    {
        return $this->normalize($prefix) . '/' . ltrim($path, '/');
    }

    private function normalize(string $prefix): string
    {
        return rtrim($prefix, '/');
    }
}

Rules:

  • No run(), execute(), handle(): only __invoke()
  • No additional public methods (SRP litmus test)
  • Class name = verb + object (BuildKey, ValidatePath, SendMessage)
  • Maximum ~150 lines: beyond that, extract sub-closures

Orchestrator = Composition

An orchestrator consumes closures and represents a functional aspect. It has no logic of its own: only chaining. Output from Closure A becomes input for Closure B, like Unix pipes.

Closures are bound in the constructor via first-class callable syntax:

php
final class Filesystem
{
    private readonly Closure $buildPath;
    private readonly Closure $validatePath;
    private readonly Closure $readFile;

    public function __construct(string $root)
    {
        $this->buildPath    = (new BuildFullPath($root))->__invoke(...);
        $this->validatePath = (new ValidatePath())->__invoke(...);
        $this->readFile     = (new ReadFile())->__invoke(...);
    }

    public function read(string $path): string
    {
        $fullPath  = ($this->buildPath)($path);
        $validated = ($this->validatePath)($fullPath);
        return ($this->readFile)($validated);
    }
}

Even when an interface requires multiple public methods (e.g. hash(), verify(), needsRehash()), the orchestrator delegates each method to its own closure. The orchestrator still has no logic of its own.

Closures as Decorators

Closures can wrap other closures, the decorator principle:

php
final class Retry
{
    private readonly Closure $transport;

    public function __construct(
        Closure $transport,
        private readonly int $maxRetries,
        private readonly int $delayMs,
    ) {
        $this->transport = $transport;
    }

    public function __invoke(Request $request): Response
    {
        $lastException = null;
        for ($i = 0; $i <= $this->maxRetries; $i++) {
            try {
                return ($this->transport)($request);
            } catch (TransportException $e) {
                $lastException = $e;
                usleep($this->delayMs * 1000 * (2 ** $i));
            }
        }
        throw $lastException;
    }
}

// In the orchestrator:
$transport = (new CurlTransport())->__invoke(...);
$retry     = (new Retry($transport, maxRetries: 3, delayMs: 100))->__invoke(...);

Retry wraps CurlTransport, just as AuthorizePermission could wrap CheckPermission. The closure signature stays the same. The caller doesn't notice.

Real-World Example: HTTP Client

Here's how the pattern looks in practice, the HTTP client as an orchestrator with handler pipeline:

HttpClient                            ← Orchestrator (PSR-18 API)
├── Handler/
│   ├── Transport/
│   │   ├── CurlTransport            ← Closure: execute cURL request
│   │   └── Retry                    ← Decorator: Retry wraps Transport
│   ├── Pipeline/
│   │   ├── ApplyBaseUrl             ← Closure: prepend base URL
│   │   ├── ApplyDefaultHeaders      ← Closure: set default headers
│   │   └── ApplyBearerToken         ← Closure: add auth header
│   └── Response/
│       └── ClassifyException        ← Closure: HTTP errors → PSR-18 exceptions
└── Config/
    └── ClientConfig                 ← Value Object: configuration

The orchestrator HttpClient has no logic of its own: it calls pipeline closures, then the transport (possibly with retry decorator), then the exception classification.

Directory Structure

All packages follow the same directory structure:

src/
├── Orchestrator1.php              ← Orchestrator in root
├── Orchestrator2.php              ← Orchestrator in root
├── Handler/                       ← ALL closures centrally
│   ├── Feature1/
│   │   ├── DoSomething.php        ← Closure
│   │   └── DoSomethingElse.php    ← Closure
│   └── Feature2/
│       └── ProcessData.php        ← Closure
├── Data/                          ← ALL value objects, enums, builders
│   ├── MyValueObject.php
│   └── MyEnum.php
└── Exception/                     ← Exceptions
    └── MyException.php

Rules:

  • Orchestrators live directly in src/ — they are the package's entry points
  • Handlers (closures) live under src/Handler/ with category subdirectories
  • Data classes (VOs, enums) live under src/Data/
  • Exceptions live under src/Exception/
  • Test fakes (e.g. InMemoryTokenStore) live under tests/Support/, not in src/

Technical Foundation

PHP 8.3+ | declare(strict_types=1) | PHPStan Level 8 | PSR-4 / PSR-12 | SOLID | Code Coverage ≥ 80%

All packages share this technical foundation. PHPStan Level 8 ensures types are fully checked. declare(strict_types=1) is set in every file. Code coverage is enforced via CI.