30 β Yakuniy senior kapston: production-grade xizmat¶
β¬ οΈ Oldingi: 29 β Navbatlar, observability va deploy Β· π README Β· Keyingi: README β‘οΈ
Bu bobda: trek nihoyasiga yetdi β endi biz hech narsa yangi o'rgatmaymiz, balki o'rganilgan hammasini bitta production-grade xizmatga quyamiz. Bitta bounded context β "Maqola" (Article) xizmati β ni noldan, hexagonal (./25) qatlamlar bilan quramiz: domen yadrosi (aggregate + Value Object + domen hodisa, ./26), application qatlami (Command + Handler, CQRS yozuv tarafi), va infrastructure adapterlari (SQLite Repository ./21, Redis kesh ./27, Messenger navbat ./28, Monolog korrelyatsiya-ID log ./29). HTTP qatlamida REST endpoint (./01) JWT/RBAC (./03/./04) middleware ortida turadi. Hammasini test (./22-./24) β unit (domen), integratsiya (adapter), static analysis darvozasi β bilan mustahkamlaymiz, ADR (Architecture Decision Record) bilan "nega hexagonal, nega CQRS" qarorlarini hujjatlaymiz, va performans byudjeti (p95 maqsadi) g'oyasini kiritamiz. Yadro oqimini β domen + use-case + SQLite adapter + bitta endpoint β bu mashinada REAL ishga tushirib ko'rsatamiz (in-process so'rov → Response); Docker/Redis-server kabi infra esa config-illustrativ (halol belgilanadi). Oxirida: trek yakuni β "0 dan PHP expertgacha" yo'li tugadi, va keyingi qadamlar.
Kapston nima va nega kerak¶
Trek davomida har bob bitta vositani chuqur o'rgatdi: REST API, JWT, tip tizimi, mini-framework, SOLID, test, static analysis, hexagonal, DDD, kesh, navbat, observability. Lekin real ish birorta vositada emas β u integratsiyada yashaydi. Intervyu beradigan senior nomzoddan so'raladigan savol "PHPStan nima?" emas, balki "production xizmatni qanday qurasiz, qatlamlarni qanday ajratasiz, nega aynan shunday?" β ya'ni qarorlar va ularning asoslari.
Kapston β shu savolga konkret javob: ishlaydigan, test qilingan, hujjatlangan xizmat. U:
- Portfolio isboti β GitHub'da ko'rsatiladigan, README + ADR + testlar bilan to'liq loyiha.
- Integratsiya mashqi β har bob alohida o'rgatgan narsa endi bir-biriga ulanadi va ziddiyatlar ko'rinadi (masalan: domen hodisa kesh-invalidatsiyani ham, email'ni ham talab qiladi β qaysi sinxron, qaysi asinxron?).
- Mulohaza β bu bobda kod kam, qaror ko'p: nega bu chegara, nega bu port, nega bu yerda CQRS, nega bu yerda emas.
Diagrammada uchta konsentrik halqa: markazda Domain (Article aggregate, Slug VO, ArticlePublished hodisa) β u hech kimni bilmaydi. O'rtada Application (Command + Handler) β domenni ishlatadi, port interfeyslarini e'lon qiladi. Tashqarida Infrastructure β SQLite, Redis, Messenger, Monolog adapterlari, ular port interfeyslariga bog'lanadi (DIP). HTTP so'rovi chap tomondan Controller orqali kiradi. Oltin qoida: strelkalar (bog'liqliklar) doimo ichkariga yo'naladi β tashqi qatlam ichkini biladi, ichki tashqini bilmaydi.
Bounded context tanlovi: "Maqola" xizmati¶
Avval chegarani belgilaymiz. DDD'da (./26) bounded context β bir tilning (ubiquitous language) amal qiladigan hududi. Bizning kontekstimiz maqola e'loni:
- Aggregate:
Articleβ id, sarlavha, slug, matn, e'lon holati. Aggregate consistency chegarasi: maqolaning ichki qoidalari (e'lon qilingan maqola qayta e'lon qilinmaydi) shu yerda majburlanadi. - Value Object:
Slugβ URL-do'st identifikator, o'z-validatsiya bilan (bo'sh emas, ≤80 belgi, faqata-z0-9-). - Domen hodisa:
ArticlePublishedβ "maqola e'lon qilindi" fakti. Bu hodisaga boshqa kontekstlar reaksiya bildiradi: email yuborish, kesh-invalidatsiya, qidiruv indeksi yangilash. - Use-case:
PublishArticleβ yagona yozuv operatsiyasi (bu kapstonda).
Nega aynan shu kontekst? U yetarlicha kichik (bitta bobga sig'adi), lekin barcha qatlam va vositani tabiiy talab qiladi: e'lon = yozuv (Repository), e'londan keyin email = navbat, o'qish = kesh, har qadam = log. Sun'iy emas β har integratsiya mantiqiy ehtiyojdan kelib chiqadi.
Domain qatlami: aggregate, VO, hodisa¶
Domen β yadro. U declare(strict_types=1) dan boshlanadi, framework, PDO, HTTP β hech narsani import qilmaydi. Faqat PHP standart tiplari va DomainException.
<?php
declare(strict_types=1);
namespace App\Domain;
use DomainException;
final class Slug
{
private string $value;
public function __construct(string $raw)
{
$slug = strtolower(trim($raw));
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
$slug = trim($slug, '-');
if ($slug === '') {
throw new DomainException('Slug bo\'sh bo\'la olmaydi');
}
if (strlen($slug) > 80) {
throw new DomainException('Slug 80 belgidan uzun');
}
$this->value = $slug;
}
public function value(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
Slug β klassik Value Object (./06): o'z-validatsiya, qiymat-tenglik (equals), immutable. Bir marta yaratilgach, noto'g'ri holatga tusha olmaydi β "yaroqsiz Slug" degan tushuncha tizimda umuman mavjud bo'lmaydi.
Endi aggregate β domen hodisasini ichida chiqaradi:
<?php
declare(strict_types=1);
namespace App\Domain;
use DomainException;
// Domen hodisasi: aggregate ichida sodir bo'lgan o'zgarmas fakt.
final class ArticlePublished
{
public function __construct(
public readonly string $articleId,
public readonly string $slug,
public readonly \DateTimeImmutable $occurredAt,
) {
}
}
final class Article
{
/** @var list<object> */
private array $events = [];
private bool $published = false;
private function __construct(
public readonly string $id,
public readonly string $title,
public readonly Slug $slug,
public readonly string $body,
) {
}
// Nomli konstruktor: yaratish qoidasi bir joyda.
public static function draft(string $id, string $title, string $body): self
{
if (trim($title) === '') {
throw new DomainException('Sarlavha bo\'sh');
}
if (strlen($body) < 10) {
throw new DomainException('Matn juda qisqa (>= 10 belgi)');
}
return new self($id, $title, new Slug($title), $body);
}
// Biznes qoidasi + hodisa: bu yerda domen mantiq yashaydi.
public function publish(\DateTimeImmutable $now): void
{
if ($this->published) {
throw new DomainException('Maqola allaqachon e\'lon qilingan');
}
$this->published = true;
$this->events[] = new ArticlePublished($this->id, $this->slug->value(), $now);
}
public function isPublished(): bool
{
return $this->published;
}
/** @return list<object> Hodisalarni olib, ro'yxatni tozalaydi. */
public function releaseEvents(): array
{
$e = $this->events;
$this->events = [];
return $e;
}
}
Diqqat qiling: publish() ni chaqirish biznes qoidasini majburlaydi (qayta e'lon mumkin emas) va hodisa qoldiradi β lekin hodisani kim qayta ishlashini bilmaydi. Domen "men e'lon qilindim" deydi; email yuborishni, kesh tozalashni esa tashqi qatlam hal qiladi. Bu β domen sofligi: aggregate mailer yoki Redis haqida hech narsa eshitmaydi.
DDD ko'prigi. Aggregate + domen hodisa naqshi ./26 β DDD da batafsil; bu yerda uni konkret kontekstda qo'llaymiz.
Application qatlami: port'lar, Command, Handler¶
Application qatlami use-case'larni boshqaradi va port'larni (interfeyslarni) e'lon qiladi. Port β domen/application "men shunday narsa kerak" deb e'lon qiladigan shartnoma; uni kim bajarishi (SQLite? MySQL? in-memory?) β infrastructure ishi.
<?php
declare(strict_types=1);
namespace App\Application;
use App\Domain\Article;
// Port: domen infra'ga emas, infra domen interfeysiga bog'lanadi (DIP).
interface ArticleRepository
{
public function save(Article $a): void;
public function ofId(string $id): ?Article;
public function existsSlug(string $slug): bool;
}
interface EventBus
{
public function publish(object $event): void;
}
interface Clock
{
public function now(): \DateTimeImmutable;
}
Clock porti β kichik, lekin muhim: vaqtni port ortiga yashirsak, testda FixedClock bilan vaqtni muzlatamiz va occurredAt ni aniq tekshiramiz. Real kodga new \DateTimeImmutable() yozsangiz, testda vaqtni nazorat qila olmaysiz.
Endi Command (CQRS yozuv tarafi β foydalanuvchi niyati) va uni bajaruvchi Handler:
<?php
declare(strict_types=1);
namespace App\Application;
// Command: immutable DTO, foydalanuvchi niyati.
final class PublishArticleCommand
{
public function __construct(
public readonly string $title,
public readonly string $body,
) {
}
}
final class PublishArticleResult
{
public function __construct(
public readonly string $id,
public readonly string $slug,
) {
}
}
// Use-case handler: HTTP ham, CLI ham, navbat ham buni chaqira oladi.
final class PublishArticleHandler
{
public function __construct(
private readonly ArticleRepository $repo,
private readonly EventBus $bus,
private readonly Clock $clock,
private readonly \Closure $idGenerator,
) {
}
public function __invoke(PublishArticleCommand $cmd): PublishArticleResult
{
$id = ($this->idGenerator)();
$article = Article::draft($id, $cmd->title, $cmd->body);
// Application-darajadagi qoida: slug noyob bo'lishi kerak.
if ($this->repo->existsSlug($article->slug->value())) {
throw new \DomainException('Bu slug band: ' . $article->slug->value());
}
$article->publish($this->clock->now());
$this->repo->save($article);
// Domen hodisalarini infra darajasiga uzatish.
foreach ($article->releaseEvents() as $event) {
$this->bus->publish($event);
}
return new PublishArticleResult($article->id, $article->slug->value());
}
}
Nega CQRS shu yerda? To'liq CQRS (alohida yozuv/o'qish modellari, hatto alohida ma'lumotlar bazasi) β bu kichik kontekst uchun ortiqcha. Lekin Command/Handler ajratmasini olamiz, chunki u use-case'ni bitta kirish nuqtasiga to'playdi: bir xil handler'ni HTTP controller ham, CLI buyruq ham, navbat consumeri ham chaqira oladi β kod takrorlanmaydi. Bu ADR'da hujjatlanadi (pastda).
Infrastructure qatlami: adapter'lar¶
Endi port'larning konkret implementatsiyalari. Bular tashqi dunyoga (DB, Redis, navbat) tegadi.
SQLite Repository (Data Mapper)¶
<?php
declare(strict_types=1);
namespace App\Infrastructure;
use App\Application\ArticleRepository;
use App\Domain\Article;
final class SqliteArticleRepository implements ArticleRepository
{
public function __construct(private readonly \PDO $pdo)
{
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
body TEXT NOT NULL,
published INTEGER NOT NULL
)'
);
}
public function save(Article $a): void
{
$st = $this->pdo->prepare(
'INSERT INTO articles (id, title, slug, body, published)
VALUES (:id, :title, :slug, :body, :published)'
);
$st->execute([
'id' => $a->id,
'title' => $a->title,
'slug' => $a->slug->value(),
'body' => $a->body,
'published' => $a->isPublished() ? 1 : 0,
]);
}
public function ofId(string $id): ?Article
{
$st = $this->pdo->prepare('SELECT * FROM articles WHERE id = :id');
$st->execute(['id' => $id]);
$row = $st->fetch(\PDO::FETCH_ASSOC);
if ($row === false) {
return null;
}
// Data Mapper: qatorni domen obyektiga tiklash.
$a = Article::draft((string) $row['id'], (string) $row['title'], (string) $row['body']);
if ((int) $row['published'] === 1) {
$a->publish(new \DateTimeImmutable());
$a->releaseEvents(); // tiklashda hodisa qayta chiqmasin
}
return $a;
}
public function existsSlug(string $slug): bool
{
$st = $this->pdo->prepare('SELECT 1 FROM articles WHERE slug = :slug');
$st->execute(['slug' => $slug]);
return $st->fetchColumn() !== false;
}
}
Repository domen interfeysini bajaradi, prepared statement (./21) ishlatadi va qatorni domen obyektiga xaritalaydi (Data Mapper). Production'da SQLite o'rniga MySQL PDO keladi β interfeys o'zgarmaydi, faqat DSN va jadval sintaksisi.
Soat va in-process event bus¶
<?php
declare(strict_types=1);
namespace App\Infrastructure;
use App\Application\Clock;
use App\Application\EventBus;
final class FixedClock implements Clock
{
public function __construct(private readonly \DateTimeImmutable $fixed)
{
}
public function now(): \DateTimeImmutable
{
return $this->fixed;
}
}
// In-process bus: listener'larni chaqiradi. Production'da Messenger transport.
final class InMemoryEventBus implements EventBus
{
/** @var array<class-string, list<callable>> */
private array $listeners = [];
public function subscribe(string $eventClass, callable $listener): void
{
$this->listeners[$eventClass][] = $listener;
}
public function publish(object $event): void
{
foreach ($this->listeners[$event::class] ?? [] as $listener) {
$listener($event);
}
}
}
InMemoryEventBus β sodda, sinxron versiya. Production'da uni Symfony Messenger (./28) bilan almashtiramiz: hodisa navbatga tushadi, fon worker email yuboradi. Port (EventBus) o'zgarmaydi β bu portning kuchi.
HTTP qatlami: Controller, Router, mini-framework adapter¶
Eng tashqi qatlam β HTTP. Bu yerda biz mini-frameworkdan (./15) Request/Response/Router ni olamiz (bobda soddalashtirilgan ko'rinishi).
<?php
declare(strict_types=1);
namespace App\Http;
final class Request
{
public function __construct(
public readonly string $method,
public readonly string $path,
/** @var array<string,mixed> */
public readonly array $body = [],
) {
}
}
final class Response
{
public function __construct(
public readonly int $status,
/** @var array<string,mixed> */
public readonly array $body,
) {
}
}
final class ArticleController
{
public function __construct(
private readonly \App\Application\PublishArticleHandler $handler,
) {
}
public function publish(Request $req): Response
{
$title = (string) ($req->body['title'] ?? '');
$body = (string) ($req->body['body'] ?? '');
try {
$cmd = new \App\Application\PublishArticleCommand($title, $body);
$result = ($this->handler)($cmd);
return new Response(201, [
'id' => $result->id,
'slug' => $result->slug,
]);
} catch (\DomainException $e) {
// RFC 7807 Problem Details (soddalashtirilgan).
return new Response(422, [
'type' => 'about:blank',
'title' => 'Validatsiya xatosi',
'detail' => $e->getMessage(),
]);
}
}
}
Controller yupqa (./01): u faqat HTTP'ni domen tiliga tarjima qiladi (body → Command), handler'ni chaqiradi va natijani HTTP javobiga (Response) qaytaradi. Biznes qoidasi controllerda emas β u handlerda va domenda. DomainException → 422 xaritalash ham shu yerda.
Auth ko'prigi. Real loyihada bu controller oldida JWT/RBAC middleware turadi (./04/./03):
POST /articlesfaqateditorrolidagi foydalanuvchiga ruxsat. Middleware pipeline'i ./12 da; bu kapstonda biz domen oqimiga e'tibor qaratamiz, auth middleware'ni mavjud deb hisoblaymiz.
Hammasini ulash va REAL ishga tushirish¶
Endi bootstrap β barcha qatlamni ulaymiz (production'da bu DI konteyner ishi, ./13) va in-process so'rov yuboramiz:
<?php
declare(strict_types=1);
namespace App\Bootstrap;
use App\Application\PublishArticleHandler;
use App\Domain\ArticlePublished;
use App\Http\ArticleController;
use App\Http\Request;
use App\Http\Router;
use App\Infrastructure\FixedClock;
use App\Infrastructure\InMemoryEventBus;
use App\Infrastructure\SqliteArticleRepository;
$pdo = new \PDO('sqlite::memory:');
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$repo = new SqliteArticleRepository($pdo);
$bus = new InMemoryEventBus();
$clock = new FixedClock(new \DateTimeImmutable('2026-06-12 10:00:00'));
// Domen hodisasiga "fon ishi" obuna (production'da navbatga qo'yiladi).
$emailsSent = [];
$bus->subscribe(ArticlePublished::class, function (ArticlePublished $e) use (&$emailsSent): void {
$emailsSent[] = "email: maqola {$e->slug} e'lon qilindi ({$e->occurredAt->format('H:i')})";
});
$counter = 0;
$idGen = fn (): string => 'art_' . (++$counter);
$handler = new PublishArticleHandler($repo, $bus, $clock, $idGen);
$controller = new ArticleController($handler);
$router = new Router();
$router->add('POST', '/articles', [$controller, 'publish']);
// --- So'rov 1: muvaffaqiyatli e'lon ---
$res1 = $router->dispatch(new Request('POST', '/articles', [
'title' => 'Hexagonal arxitektura PHP da',
'body' => 'Bu kapston maqolasi domen yadrosini ko\'rsatadi.',
]));
echo "[1] status={$res1->status} body=" . json_encode($res1->body, JSON_UNESCAPED_UNICODE) . "\n";
// --- So'rov 2: bir xil slug (konflikt) ---
$res2 = $router->dispatch(new Request('POST', '/articles', [
'title' => 'Hexagonal arxitektura PHP da',
'body' => 'Boshqa matn, lekin sarlavha bir xil -> slug band.',
]));
echo "[2] status={$res2->status} detail=" . ($res2->body['detail'] ?? '-') . "\n";
// --- So'rov 3: validatsiya xatosi (qisqa matn) ---
$res3 = $router->dispatch(new Request('POST', '/articles', ['title' => 'OK', 'body' => 'qisqa']));
echo "[3] status={$res3->status} detail=" . ($res3->body['detail'] ?? '-') . "\n";
// --- Domen hodisasi fon ishini ishga tushirdimi? ---
foreach ($emailsSent as $line) {
echo "[event] $line\n";
}
Bu kod β domen, application, infrastructure (SQLite + in-memory bus) va HTTP qatlamlarini bitta faylga yig'ib (bobda alohida fayllar, PSR-4 autoload bilan), bu mashinada PHP 8.4 da REAL ishga tushirildi. Haqiqiy chiqish:
[1] status=201 body={"id":"art_1","slug":"hexagonal-arxitektura-php-da"}
[2] status=422 detail=Bu slug band: hexagonal-arxitektura-php-da
[3] status=422 detail=Matn juda qisqa (>= 10 belgi)
[event] email: maqola hexagonal-arxitektura-php-da e'lon qilindi (10:00)
Bitta oqimda barcha qatlam ishladi: birinchi so'rov 201 (maqola yaratildi, slug avtomatik generatsiya qilindi), ikkinchi 422 (slug band β application qoidasi), uchinchi 422 (qisqa matn β domen qoidasi). Va eng muhimi: [event] qatori β ArticlePublished domen hodisasi fon ishini (email) ishga tushirdi. Bu β hexagonal oqimning to'liq isboti.
O'qish tarafi: Redis kesh (cache-aside)¶
Yozuv tarafi tayyor. Endi o'qish β uni keshlash kerak (./27). PSR-16 uslubidagi interfeys ostida, Redis bo'lmasa array fallback (degrade) ishlaydi:
<?php
declare(strict_types=1);
interface SimpleCache
{
public function get(string $key, mixed $default = null): mixed;
public function set(string $key, mixed $value, int $ttl = 0): bool;
public function delete(string $key): bool;
}
final class ArrayCache implements SimpleCache
{
/** @var array<string, array{value:mixed, expires:int}> */
private array $store = [];
public function get(string $key, mixed $default = null): mixed
{
$item = $this->store[$key] ?? null;
if ($item === null) {
return $default;
}
if ($item['expires'] !== 0 && $item['expires'] < time()) {
unset($this->store[$key]);
return $default;
}
return $item['value'];
}
public function set(string $key, mixed $value, int $ttl = 0): bool
{
$this->store[$key] = ['value' => $value, 'expires' => $ttl > 0 ? time() + $ttl : 0];
return true;
}
public function delete(string $key): bool
{
unset($this->store[$key]);
return true;
}
}
// Cache-aside (read-through): kesh bo'lsa kesh, bo'lmasa "DB" + saqlash.
final class CachedArticleReader
{
private int $dbHits = 0;
public function __construct(private readonly SimpleCache $cache)
{
}
public function read(string $slug): array
{
$key = "article:$slug";
$cached = $this->cache->get($key);
if ($cached !== null) {
return ['source' => 'cache', 'data' => $cached];
}
$this->dbHits++; // sekin "DB" o'qishi simulyatsiyasi
$data = ['slug' => $slug, 'title' => "Maqola: $slug"];
$this->cache->set($key, $data, 60);
return ['source' => 'db', 'data' => $data];
}
public function dbHits(): int
{
return $this->dbHits;
}
}
$reader = new CachedArticleReader(new ArrayCache());
$r1 = $reader->read('hexagonal-php');
$r2 = $reader->read('hexagonal-php'); // bu safar keshdan
echo "read1 source={$r1['source']}\n";
echo "read2 source={$r2['source']}\n";
echo "db hits={$reader->dbHits()} (kutilgan: 1)\n";
Bu bu mashinada REAL ishga tushirildi (ArrayCache β sof PHP). Chiqish:
Birinchi o'qish DB'ga bordi va keshga yozdi; ikkinchi o'qish keshdan keldi β DB faqat 1 marta chaqirildi. Bu cache-aside naqshining mohiyati: o'qishlar arzon, DB bosimi kamayadi.
Halol belgi β Redis SERVER. Bu mashinada
ext-rediso'rnatilgan, lekin:6379portda Redis serveri ishlamaydi. Shuning uchun yuqoridaArrayCache(real, in-process) ishlatildi. Production'daSimpleCacheniRedisCachebilan almashtirasiz β interfeys o'zgarmaydi:Bu kod yaroqli, lekinfinal class RedisCache implements SimpleCache { public function __construct(private readonly \Redis $redis) {} public function get(string $key, mixed $default = null): mixed { $raw = $this->redis->get($key); return $raw === false ? $default : unserialize($raw); } public function set(string $key, mixed $value, int $ttl = 0): bool { return $ttl > 0 ? $this->redis->setex($key, $ttl, serialize($value)) : (bool) $this->redis->set($key, serialize($value)); } public function delete(string $key): bool { return $this->redis->del($key) >= 0; } } // Ulanish (muhitingizda Redis server kerak): // $redis = new \Redis(); $redis->connect('127.0.0.1', 6379);connect()ni ishga tushirish uchun muhitingizda Redis server kerak (docker run -p 6379:6379 redis). Kapston portfolio'dadocker-composebilan Redis ko'tariladi.
Kesh-invalidatsiya: maqola yangilanganda yoki o'chirilganda $cache->delete("article:$slug") β bu ham ArticlePublished/ArticleUpdated hodisasiga obuna bo'lgan listener ishi. Domen hodisa ikki narsani boshqaradi: email (navbat) va kesh tozalash.
Navbat: Symfony Messenger bilan fon ishi¶
InMemoryEventBus sinxron edi β email yuborish HTTP so'rovini sekinlashtiradi. Production'da email fon worker'ga (./28) o'tadi. Symfony Messenger in-memory transporti bilan buni REAL ko'rsatamiz:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Symfony\Component\Messenger\MessageBus;
use Symfony\Component\Messenger\Handler\HandlersLocator;
use Symfony\Component\Messenger\Middleware\HandleMessageMiddleware;
// Sekin ish: e'lon qilingach email yuborish (fon worker ishi).
final class SendPublishEmail
{
public function __construct(public readonly string $slug)
{
}
}
$received = [];
$bus = new MessageBus([
new HandleMessageMiddleware(new HandlersLocator([
SendPublishEmail::class => [
function (SendPublishEmail $msg) use (&$received): void {
$received[] = "email yuborildi: {$msg->slug}";
},
],
])),
]);
$envelope = $bus->dispatch(new SendPublishEmail('hexagonal-php'));
echo "dispatch tugadi, message=" . $envelope->getMessage()::class . "\n";
foreach ($received as $line) {
echo "[worker] $line\n";
}
Bu bu mashinada composer require symfony/messenger bilan o'rnatilib REAL ishga tushirildi. Chiqish:
Kapstonda PublishArticleHandler ichidagi $this->bus->publish($event) ni Messenger'ning dispatch() ga ulaymiz: ArticlePublished hodisasi SendPublishEmail xabariga aylanib navbatga tushadi. In-memory transport sinxron ishladi (test uchun qulay); production'da AMQP/Redis transport + messenger:consume worker ishlatiladi β bu alohida runtime/server talab qiladi (muhitingizda broker server kerak).
Observability: Monolog korrelyatsiya-ID¶
Production xizmatda kuzatuvchanlik (observability) shart (./29): har so'rovga korrelyatsiya-ID beriladi va shu so'rov davomidagi barcha log yozuvi shu ID bilan belgilanadi β keyin logda bitta so'rovni boshidan oxirigacha kuzatish mumkin. Monolog processor bilan:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Monolog\Logger;
use Monolog\Level;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;
use Monolog\LogRecord;
$correlationId = bin2hex(random_bytes(4)); // har so'rovda yangi
$logger = new Logger('capstone');
$handler = new StreamHandler('php://stdout', Level::Info);
$handler->setFormatter(new JsonFormatter());
$logger->pushHandler($handler);
// Processor: har log yozuviga correlation_id qo'shadi.
$logger->pushProcessor(function (LogRecord $record) use ($correlationId): LogRecord {
$record->extra['correlation_id'] = $correlationId;
return $record;
});
$logger->info('maqola e\'lon qilindi', ['slug' => 'hexagonal-php', 'status' => 201]);
$logger->warning('slug band', ['slug' => 'hexagonal-php']);
Bu bu mashinada composer require monolog/monolog bilan o'rnatilib REAL ishga tushirildi. Chiqish (JSON, bitta qatorda β bu yerda o'qish uchun ko'rsatilgan):
{"message":"maqola e'lon qilindi","context":{"slug":"hexagonal-php","status":201},
"level":200,"level_name":"INFO","channel":"capstone",
"datetime":"2026-06-12T...","extra":{"correlation_id":"a22c1242"}}
{"message":"slug band","context":{"slug":"hexagonal-php"},
"level":300,"level_name":"WARNING","channel":"capstone",
"datetime":"2026-06-12T...","extra":{"correlation_id":"a22c1242"}}
Ikkala yozuvda ham extra.correlation_id bir xil (a22c1242) β chunki ular bitta so'rov ichida. Production'da log agregatori (Loki, ELK) shu ID bo'yicha filtr beradi: "shu xato qaysi so'rovdan keldi?" degan savolga bir soniyada javob.
Global handler ham qo'yiladi β tutilmagan xato/o'lim holatini ham log qiladi:
set_exception_handler(function (\Throwable $e) use ($logger): void {
$logger->error('tutilmagan istisno', ['type' => $e::class, 'msg' => $e->getMessage()]);
http_response_code(500);
echo json_encode(['title' => 'Server xatosi'], JSON_UNESCAPED_UNICODE);
});
register_shutdown_function(function () use ($logger): void {
$err = error_get_last();
if ($err !== null && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR], true)) {
$logger->critical('fatal shutdown', ['error' => $err['message']]);
}
});
set_exception_handler va register_shutdown_function β PHP core, bu mashinada REAL ishlaydi (testlarda tasdiqlangan). Ular "oxirgi himoya chizig'i": hech bir try/catch tutmagan xato ham toza JSON javob va log qoldiradi.
Performans byudjeti: p95 maqsadi¶
Senior xizmatning performans byudjeti bo'ladi β oldindan kelishilgan maqsad, masalan: POST /articles uchun p95 javob vaqti < 200ms. "p95" = so'rovlarning 95% shu vaqtdan tez bajariladi (median'dan qattiqroq, chunki u sekin "dum"ni hisobga oladi).
Byudjet qaror beruvchi: agar yangi xususiyat p95 ni 200ms dan oshirsa β yo optimallashtiramiz, yo byudjetni ataylab o'zgartiramiz (qaror ADR'ga yoziladi). Byudjetni qanday himoya qilamiz:
| Texnika | Bobda | Ta'siri |
|---|---|---|
| O'qishlarni keshlash (Redis) | ./27 | DB bosimini kamaytiradi, p95 pasayadi |
| Sekin ishni navbatga (email) | ./28 | HTTP so'rovi email'ni kutmaydi |
| OPcache + preloading | ./29 | har so'rovda PHP qayta kompilyatsiya qilinmaydi |
| N+1 ni yo'qotish (batch) | ./21 | sikl ichida so'rov o'rniga WHERE id IN (...) |
| DB indeks (slug UNIQUE) | ./21 | existsSlug tez ishlaydi |
OPcache holatini bu mashinada tekshirdik β opcache_get_status() REAL ishlaydi (opcache_enabled => true), ya'ni CLI'da ham OPcache yoqilgan. Production o'lchov (real p95) esa APM (Application Performance Monitoring) talab qiladi: Blackfire/SPX/Xdebug profiler yoki Prometheus + histogram. Bular bu mashinada yo'q β muhitingizda profiler/APM o'rnatishingiz kerak. Byudjet g'oyasi muhim: raqamsiz "tez" degan so'z ma'nosiz; o'lchanadigan maqsad β muhandislik.
Deploy: Dockerfile, docker-compose, .env¶
Kapston konteynerlanadi (./29). Quyidagilar real, yaroqli config β lekin ularni qurish/ishga tushirish uchun muhitingizda Docker kerak (bu mashinada Docker ishlamaydi, shuning uchun illustrativ, halol belgilanadi).
# Dockerfile β production PHP-FPM image
FROM php:8.4-fpm-alpine
RUN apk add --no-cache $PHPIZE_DEPS \
&& docker-php-ext-install pdo pdo_mysql opcache \
&& pecl install redis && docker-php-ext-enable redis \
&& apk del $PHPIZE_DEPS
# OPcache + preloading (har so'rovda qayta kompilyatsiya qilinmasin)
COPY docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction
COPY . .
CMD ["php-fpm"]
# docker-compose.yml β xizmat + bog'liq infra
services:
app:
build: .
environment:
DATABASE_URL: "mysql://app:secret@db:3306/articles"
REDIS_URL: "redis://cache:6379"
MESSENGER_TRANSPORT_DSN: "redis://cache:6379/messages"
depends_on: [db, cache]
worker: # navbat consumeri (alohida konteyner)
build: .
command: php bin/console messenger:consume async --time-limit=3600
depends_on: [db, cache]
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: articles
MYSQL_USER: app
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: root
cache:
image: redis:7-alpine
; docker/opcache.ini β production OPcache sozlamasi
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; prod: fayl o'zgarishini tekshirmaydi (tez)
opcache.preload=/app/preload.php
# .env (NAMUNA β sirlar git'ga TUSHMAYDI, faqat .env.example tushadi)
APP_ENV=prod
DATABASE_URL="mysql://app:secret@db:3306/articles"
REDIS_URL="redis://cache:6379"
JWT_SECRET="<kuchli-tasodifiy-kalit>"
Diqqat: worker β alohida konteyner, u navbatni iste'mol qiladi. validate_timestamps=0 production'da OPcache'ni eng tez rejimga qo'yadi (deploy'da konteyner qayta quriladi, shuning uchun fayl o'zgarishini tekshirish shart emas). Sirlar .env da, lekin .env git'ga tushmaydi β faqat .env.example namuna.
Deploy mexanikasi ko'prigi. Bu config'lar nimani deploy qilishni ko'rsatadi; qanday deploy qilish (CI/CD pipeline, GitHub Actions, secret management, blue-green) foydalanuvchining Git va GitHub kitobida batafsil. Kapston repo'siga
.github/workflows/ci.ymlqo'shib,composer check(lint + stan + test) ni har PR'da ishga tushirasiz.
ADR: Architecture Decision Record¶
Senior loyihaning belgisi β qarorlar hujjatlangan. ADR β qisqa markdown fayl (docs/adr/0001-*.md), bir muhim qarorni va nega ni yozadi. Bu β kelajakdagi siz va jamoa uchun: "nega bu shunday?" degan savol oylar keyin ham javobga ega bo'ladi.
# ADR 0001: Hexagonal arxitektura tanlovi
## Holat
Qabul qilingan (2026-06-12)
## Kontekst
"Maqola" xizmati DB, Redis, navbat va email bilan integratsiya qiladi.
Biznes mantiq (e'lon qoidalari) bu detallarga bog'lanib qolsa, test qiyin
va detal o'zgarishi (MySQL -> Postgres) domenni buzadi.
## Qaror
Hexagonal (port/adapter) arxitektura. Domen va application qatlami port
interfeyslarini e'lon qiladi (ArticleRepository, EventBus, Clock);
infrastructure ularni bajaradi. Bog'liqlik doimo ichkariga.
## Oqibatlar
(+) Domenni adapter'siz, in-memory double bilan tez test qilamiz.
(+) DB/kesh/navbat'ni domenga tegmasdan almashtiramiz.
(-) Ko'proq interfeys/fayl (boilerplate) -- kichik CRUD uchun ortiqcha
bo'lishi mumkin; shuning uchun faqat murakkab kontekstga qo'llaymiz.
# ADR 0002: CQRS o'rniga yengil Command/Handler
## Kontekst
To'liq CQRS (alohida o'qish modeli, event sourcing) bu kichik kontekst
uchun ortiqcha murakkablik.
## Qaror
Faqat yozuv tarafida Command + Handler ajratmasini olamiz; o'qish tarafi
oddiy keshli reader. Event sourcing YO'Q -- holat to'g'ridan DB'da.
## Oqibatlar
(+) Use-case bitta kirish nuqtasida; HTTP/CLI/navbat bir handler chaqiradi.
(+) Murakkablik kontekst ehtiyojiga mos (over-engineering yo'q, [./19]).
(-) Kelajakda murakkab hisobotlar kerak bo'lsa, o'qish modelini ajratamiz
-- o'shanda yangi ADR yoziladi.
ADR'ning kuchi β u qaror trade-off'ini (- qatorlari) ham yozadi. Bu intervyu va kod-review'da senior fikrlashning isboti: siz nafaqat "shunday qildim", balki "shu sababdan, shu narxga" deysiz.
Test darvozasi: unit + integratsiya + static¶
Kapston uch darajali test bilan himoyalanadi (test piramidasi, ./22):
- Unit (domen) β eng ko'p, eng tez.
Slugvalidatsiyasi,Article::publish()qayta-e'lon qoidasi, hodisa chiqishi. Adapter yo'q β sof PHP. - Integratsiya (adapter) β kamroq.
SqliteArticleRepositoryni real (sqlite in-memory) DB bilan:save()→ofId()aylanasi,existsSlugUNIQUE. - Static analysis β PHPStan level max + PHP-CS-Fixer (./24), har PR'da
composer checkdarvozasi.
Domen unit testining namunasi (PHPUnit, ./22):
<?php
declare(strict_types=1);
use App\Domain\Article;
use App\Domain\ArticlePublished;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
final class ArticleTest extends TestCase
{
#[Test]
public function publish_qayta_chaqirilsa_xato(): void
{
$a = Article::draft('art_1', 'Sarlavha', 'Yetarli uzunlikdagi matn.');
$a->publish(new DateTimeImmutable());
$this->expectException(DomainException::class);
$a->publish(new DateTimeImmutable()); // qayta e'lon -> portlaydi
}
#[Test]
public function publish_hodisa_chiqaradi(): void
{
$a = Article::draft('art_1', 'Sarlavha', 'Yetarli uzunlikdagi matn.');
$a->publish(new DateTimeImmutable('2026-06-12 10:00'));
$events = $a->releaseEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ArticlePublished::class, $events[0]);
self::assertSame('sarlavha', $events[0]->slug);
}
}
Bu testlar domen sofligining natijasi: domen adapter'ni bilmagani uchun, testda DB/Redis/navbat umuman kerak emas β millisekundlarda ishlaydi. Hexagonal arxitekturaning eng amaliy foydasi shu: tez, ishonchli domen testi.
Trek yakuni: 0 dan PHP expertgacha¶
Mana 30 bob ortda qoldi. Yo'lni qaytarib ko'raylik:
- Wave 1 (01-04) β REST API, HTTP klient, RBAC, JWT. "So'rovga to'g'ri, xavfsiz javob bera olaman."
- Wave 2 (05-10) β PHP 8.4 tip tizimi, Value Object, property hooks, reflection, WeakMap, PSR. "Tilning chuqurligini bilaman."
- Wave 3 (11-16) β PSR-7/15, DI konteyner, routing, mini-framework, Twig. "Framework ichini bilaman, kerak bo'lsa o'zim yig'aman."
- Wave 4 (17-24) β fayllar/oqimlar, formatlar, SOLID, GoF patterns, taktik dizayn, PHPUnit/Pest/mutation, static analysis. "Sifatni mashina majburlaydi, men mantiqqa e'tibor beraman."
- Wave 5 (25-29) β hexagonal, DDD, Redis kesh, Messenger navbat, observability + deploy. "Production-grade xizmat qura olaman."
- 30 β Kapston β hammasini bitta xizmatga quydik. "Bularning hammasi birga ishlaydi, qarorlarni asoslay olaman."
"Expert" β hamma narsani yoddan bilish emas. Expert = (1) chuqur tushuncha (nega shunday, qachon boshqacha), (2) qaror qila olish (trade-off ko'rish, ADR yozish), (3) sifatni intizom bilan ta'minlash (test + static + CI). Bu uchchovi sizda endi bor.
Keyingi qadamlar¶
Trek tugadi, lekin o'rganish tugamaydi. Keyingi yo'nalishlar:
- Real loyiha quring. Kapstonni o'zingizning g'oyangizga moslang (blog → SaaS, e-tijorat, API platforma). Eng yaxshi o'rganish β haqiqiy foydalanuvchili loyiha.
- Open-source hissa. Symfony, Laravel yoki sevimli paketingizga PR yuboring. Boshqalarning production kodi β eng yaxshi maktab.
- Laravel / Symfony chuqur. Endi siz internalsni bilasiz (DI, router, middleware o'zingiz yozdingiz) β framework "sehr"i emas, tanish naqshlar. Birini chuqur o'rganing.
- System design intervyu. Kapston β bitta xizmat; keyingisi β ko'p xizmat: yuk balansi, ma'lumotlar bazasi sharding, xabar brokerlari, CAP teoremasi. Performans byudjeti g'oyasi shu yerda kengayadi.
- Portfolio. Kapstonni GitHub'ga README + ADR + CI badge bilan qo'ying β Git va GitHub kitobi bilan toza tarix, mazmunli commit, PR intizomi.
- Kuzatuv chuqurligi. OpenTelemetry, Sentry, Prometheus + Grafana bilan production'ni real kuzatish (bu mashinada yo'q β alohida xizmatlar).
Siz endi "0 dan expert" yo'lini bosib o'tdingiz. Yo'lda eng muhimi β intizom: test yozish, qaror asoslash, sifatni mashina bilan himoya qilish. Bu intizom sizni "kod yozadigan" dasturchidan "tizim quradigan muhandis"ga aylantiradi.
Mashqlar¶
Bu bob loyiha-asosli β mashqlar ham kapstonni kengaytirishga qaratilgan. Yo'riqnoma: har mashqni o'z repongizda alohida branch'da bajaring, ADR yozing, test qo'shing.
Oson¶
-
ArticleUpdatedhodisasi.Articlegaupdate(string $newBody)metodini qo'shing: matn o'zgaradi vaArticleUpdateddomen hodisasi chiqadi. Unit test bilan tasdiqlang: hodisa chiqdimi, e'lon qilinmagan maqolani yangilash mumkinmi? -
Kesh-invalidatsiya listeneri.
ArticleUpdatedhodisasiga obuna bo'lib,$cache->delete("article:$slug")chaqiradigan listener yozing. In-memory bus vaArrayCachebilan test: yangilashdan keyin kesh tozalandimi?
O'rta¶
-
GetArticlequery (o'qish tarafi). Yangi use-case: slug bo'yicha maqola o'qish.CachedArticleReaderni Repository bilan ulang (cache-aside: avval kesh, bo'lmasa repo).GET /articles/{slug}endpoint qo'shing, 200/404 qaytaring. p95 byudjet g'oyasini izohlang: kesh nega p95 ni pasaytiradi? -
Messenger'ni handler'ga ulash.
PublishArticleHandlerdagiInMemoryEventBusniEventBusportini bajaradigan Messenger adapter bilan almashtiring ($bus->dispatch($event)). Port o'zgarmasligini, faqat adapter o'zgarishini ko'rsating.
Qiyin¶
-
To'liq integratsiya testi.
POST /articlesendpoint'ini boshidan oxirigacha test qiling: real sqlite repo, in-memory bus, Monolog (test handler bilan log yig'ish). Bitta testda: 201 javob + DB'da qator + hodisa chiqdi + log yozildi. Bu kapston "tirik" ekanining isboti. -
ADR 0003 yozing. O'zingiz qo'shgan xususiyat (masalan o'qish keshi yoki Messenger ulash) uchun ADR yozing: kontekst, qaror, oqibatlar (jumladan kamida bitta trade-off
-). Keyin yangi a'zoga "nega shunday?" deb tushuntirgandek o'qib chiqing β javob aniqmi?
Yechim β 1
Article ga metod va hodisa qo'shamiz. E'lon qilinmagan maqolani yangilash mumkin emas degan qoida domenda majburlanadi:
final class ArticleUpdated
{
public function __construct(
public readonly string $articleId,
public readonly \DateTimeImmutable $occurredAt,
) {}
}
// Article ichida:
public function update(string $newBody, \DateTimeImmutable $now): void
{
if (!$this->published) {
throw new \DomainException('Faqat e\'lon qilingan maqola yangilanadi');
}
if (strlen($newBody) < 10) {
throw new \DomainException('Matn juda qisqa');
}
// body readonly bo'lgani uchun real loyihada Article mutable yoki
// withBody() immutable nusxa qaytaradi; bu yerda hodisa muhim.
$this->events[] = new ArticleUpdated($this->id, $now);
}
Test: e'lon qilinmagan maqolada update() → DomainException; e'lon qilingan maqolada update() → releaseEvents() da ArticleUpdated bo'ladi. Asosiy saboq: yangilash qoidasi (faqat e'lon qilingandan keyin) domenda yashaydi, controllerda emas.
Yechim β 2
Listener β port (EventBus) orqali obuna bo'ladi, kesh portiga (SimpleCache) bog'lanadi:
$cache = new ArrayCache();
$cache->set('article:eski-slug', ['title' => 'eski'], 60); // kesh to'la
$bus = new InMemoryEventBus();
$bus->subscribe(ArticleUpdated::class, function (ArticleUpdated $e) use ($cache): void {
// Realda slug hodisada bo'ladi; bu yerda sodda misol.
$cache->delete('article:eski-slug');
});
assert($cache->get('article:eski-slug') !== null); // tozalashdan oldin bor
$bus->publish(new ArticleUpdated('art_1', new DateTimeImmutable()));
assert($cache->get('article:eski-slug') === null); // tozalandi
echo "kesh-invalidatsiya ishladi\n";
Saboq: bitta domen hodisasi (ArticleUpdated) bir nechta mustaqil reaksiyani boshqaradi β email, kesh tozalash, qidiruv indeks. Har biri alohida listener; ular bir-birini bilmaydi. Bu β domen hodisasi naqshining kuchi: yangi reaksiya qo'shish uchun handler'ni o'zgartirmaysiz, yangi listener qo'shasiz (OCP, ./19).
Yechim β 3
GetArticle β query tarafi (CQRS o'qish). Cache-aside reader Repository bilan:
final class GetArticleQuery
{
public function __construct(public readonly string $slug) {}
}
final class GetArticleHandler
{
public function __construct(
private readonly ArticleRepository $repo,
private readonly SimpleCache $cache,
) {}
public function __invoke(GetArticleQuery $q): ?array
{
$key = "article:{$q->slug}";
$cached = $this->cache->get($key);
if ($cached !== null) {
return $cached; // tez yo'l
}
$article = $this->repo->ofSlug($q->slug); // repo'ga ofSlug qo'shiladi
if ($article === null) {
return null; // controller -> 404
}
$view = ['slug' => $article->slug->value(), 'title' => $article->title];
$this->cache->set($key, $view, 300);
return $view;
}
}
Controller: $view === null ? new Response(404, ...) : new Response(200, $view).
p95 byudjet izohi: o'qishlar yozuvlardan ko'p marta ko'p (oddiy blogda 100:1). Agar har o'qish DB'ga borsa, DB p95 ning eng katta hissadori bo'ladi. Kesh bilan mashhur maqolalar (eng ko'p o'qiladigan) keshdan keladi β DB faqat birinchi o'qishda va kesh muddati tugaganda chaqiriladi. Natija: p95 keskin pasayadi, chunki ko'pchilik so'rov kesh tezligida (mikrosekundlar) ishlaydi. Byudjet (p95 < 200ms) shu texnika bilan himoyalanadi.
Yechim β 4
Messenger adapteri EventBus portini bajaradi β handler kodi umuman o'zgarmaydi:
use Symfony\Component\Messenger\MessageBusInterface;
final class MessengerEventBus implements \App\Application\EventBus
{
public function __construct(private readonly MessageBusInterface $bus) {}
public function publish(object $event): void
{
$this->bus->dispatch($event); // navbatga (yoki sinxron, transportga qarab)
}
}
Bootstrap'da almashtirish:
// Eski: $bus = new InMemoryEventBus();
$bus = new MessengerEventBus($symfonyMessageBus);
$handler = new PublishArticleHandler($repo, $bus, $clock, $idGen); // o'zgarmadi!
Saboq: PublishArticleHandler EventBus portiga bog'langani uchun, uning ichidagi $this->bus->publish($event) qatori bir xil qoladi. In-memory bus (test) yoki Messenger (prod) β handler farqini sezmaydi. Bu β port/adapter ajratmasining butun maqsadi: detal (navbat texnologiyasi) o'zgaradi, mantiq (use-case) tegilmaydi. ADR 0001 (hexagonal) ning amaliy daromadi shu yerda ko'rinadi.
Yechim β 5
To'liq integratsiya testi β barcha qatlamni real (lekin in-memory) adapter bilan ulaydi:
use Monolog\Logger;
use Monolog\Handler\TestHandler; // log'ni xotirada yig'adi
public function test_publish_oqimi_uchidan_uchiga(): void
{
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$repo = new SqliteArticleRepository($pdo);
$bus = new InMemoryEventBus();
$logHandler = new TestHandler();
$logger = new Logger('test');
$logger->pushHandler($logHandler);
$published = [];
$bus->subscribe(ArticlePublished::class, function ($e) use (&$published, $logger) {
$published[] = $e;
$logger->info('hodisa', ['slug' => $e->slug]);
});
$handler = new PublishArticleHandler(
$repo, $bus, new FixedClock(new DateTimeImmutable('2026-06-12 10:00')),
fn () => 'art_1'
);
$controller = new ArticleController($handler);
$res = $controller->publish(new Request('POST', '/articles', [
'title' => 'Integratsiya testi', 'body' => 'Yetarli uzunlikdagi matn.',
]));
self::assertSame(201, $res->status); // HTTP
self::assertNotNull($repo->ofId('art_1')); // DB'da qator
self::assertCount(1, $published); // hodisa chiqdi
self::assertTrue($logHandler->hasInfoThatContains('hodisa')); // log yozildi
}
Bitta test to'rt qatlamning ulanishini tasdiqlaydi: HTTP (201), persistence (DB qator), domen hodisa (listener ishladi), observability (log). TestHandler β Monolog'ning test uchun maxsus handleri, log'ni xotirada yig'adi va hasInfoThatContains() bilan tekshiriladi. Bu test β kapstoning "tirik" ekanining isboti: hujjatdagi diagramma emas, ishlaydigan tizim.
Yechim β 6
ADR namunasi (o'qish keshini qo'shganingiz uchun):
# ADR 0003: O'qish tarafida cache-aside kesh
## Holat
Qabul qilingan (2026-06-12)
## Kontekst
Maqola o'qishlari yozishdan ~100 barobar ko'p. Har o'qish DB'ga borsa,
DB p95 javob vaqtining asosiy hissadori bo'ladi va byudjet (p95 < 200ms)
buziladi. Maqola mazmuni kamdan-kam o'zgaradi -> keshlash xavfsiz.
## Qaror
Cache-aside (lazy) strategiya: o'qishda avval kesh tekshiriladi, bo'lmasa
repo + keshga yozish (TTL 300s). Yozuv/yangilashda kesh `delete()` bilan
invalidatsiya qilinadi (ArticleUpdated listeneri orqali).
## Oqibatlar
(+) Mashhur maqolalar keshdan -> p95 keskin pasayadi.
(+) DB bosimi kamayadi -> arzonroq infratuzilma.
(-) Kesh muddati ichida eskirgan ma'lumot ko'rinishi mumkin (eventual
consistency). 300s TTL bilan bu xavf qabul qilinadi; real-time
aniqlik kerak bo'lsa, har yangilashda invalidatsiya kifoya qilmaydi
-> alohida strategiya kerak (o'shanda yangi ADR).
(-) Kesh-invalidatsiya bug'lari (eski ma'lumot qolib ketishi) -- test bilan
qoplanadi (5-mashq integratsiya testi).
Saboq: yaxshi ADR savolga javob bermaydi, balki qaror kontekstini saqlaydi. "Nega kesh?" degan kelajakdagi savol bu fayl bilan javobga ega: tezlik kerak edi, o'qish ko'p edi, eskirish xavfi qabul qilinadigan edi. Eng muhimi β (-) qatorlari: ular trade-offni tan oladi. Trade-off'siz ADR β marketing, qaror emas.
Yakun¶
Bu β trekning so'nggi bobi. Biz hech narsa yangi o'rgatmadik; o'rniga 29 bobning hammasini bitta production-grade xizmatga quydik:
- Hexagonal qatlamlar β domen (aggregate + VO + hodisa), application (Command + Handler + port), infrastructure (SQLite/Redis/Messenger/Monolog adapter), HTTP (controller + router). Bog'liqlik ichkariga.
- Yadro oqimi REAL ishladi β in-process
POST /articles→ 201/422/422 + domen hodisa fon ishi. Kesh (cache-aside, 1 DB hit), Messenger (in-memory bus), Monolog (correlation-id) β barchasi bu mashinada ishga tushdi. - Halol illustrativ β Redis server, Docker/compose, profiler/APM (p95 real o'lchov), navbat broker β bular muhitingizda alohida xizmat talab qiladi; config yaroqli, lekin run uchun infra kerak.
- ADR β hexagonal, yengil CQRS, kesh qarorlarini trade-off bilan hujjatladik. Senior belgisi.
- Test darvozasi β unit (domen, adapter'siz tez) + integratsiya (real sqlite) + static (PHPStan/CS-Fixer CI'da).
- Performans byudjeti β p95 maqsadi raqamli muhandislik tafakkurini beradi.
Va eng muhimi β trek tugadi. "0 dan PHP expertgacha" yo'lining 30 bobi ortda. Endi sizda chuqur tushuncha, qaror qila olish va sifat intizomi bor. Keyingi qadam β bu bilimni real loyihada ishlatish: kapstonni o'zingizniki qiling, GitHub'ga qo'ying, dunyoga ko'rsating. Omad β endi siz muhandissiz.
β¬ οΈ Oldingi: 29 β Navbatlar, observability va deploy Β· π README Β· Keyingi: README β‘οΈ