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 inwardThe 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:
// 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.
PSR Standards
Jardis consistently implements and uses PSR standards:
| Standard | Jardis Package | Description |
|---|---|---|
| PSR-3 | Logger | Logging interface |
| PSR-7 | HTTP | HTTP messages |
| PSR-11 | Factory | Container interface |
| PSR-12 | all | Coding standard |
| PSR-14 | EventDispatcher | Event system |
| PSR-16 | Cache | Simple cache |
| PSR-17 | HTTP | HTTP factories |
| PSR-18 | HTTP | HTTP 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
| Traditional | With ClassVersion |
|---|---|
| Breaking change → migrate all clients simultaneously | Old and new versions coexist at runtime |
| Feature flags and if/else chains | Architecture handles the routing decision |
| Rename class + update hundreds of imports | Class name stays the same, namespace injection handles the rest |
| Big-bang release after weeks | Migrate 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:
// 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.
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:
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:
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: configurationThe 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.phpRules:
- 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 undertests/Support/, not insrc/
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.