
Есть распространённый совет: «Хочешь писать лучше? Читай хороший код». Звучит очевидно. Делается редко.
Проблема в том, что большинство open-source проектов — лабиринты. Открываешь репу, видишь 200 директорий и закрываешь вкладку. Kubernetes — два миллиона строк. Linux kernel — даже не думай. С чего начать?
Мой ответ: etcd.
Для тех, кто не знаком: etcd — это распределённое key-value хранилище на Go. Костяк Kubernetes — там лежит всё состояние кластера. Но мне etcd интересен не как продукт. Мне он интересен как пример архитектуры, которую реально можно прочитать от начала до конца.
Что меня удивило: принципы, заложенные в etcd, — это не про Go. Это про дизайн ПО в целом. Я каждый день работаю с PHP и Symfony, и почти всё, что нашёл в etcd, переложилось напрямую на мои проекты.
Семь принципов, конкретные примеры, без воды.
1. Один источник правды для API
В etcd любой API определён в .proto-файлах. Открываешь rpc.proto и видишь все операции: Range, Put, DeleteRange, Txn. Каждое поле типизировано. Нет места для «погоди, мы здесь принимаем строку или число?».
В PHP вместо protobuf у нас строго типизированные DTO:
final readonly class CreateOrderRequest
{
public function __construct(
public string $customerId,
/** @var OrderItemDto[] */
public array $items,
public ?string $promoCode = null,
) {}
}
Один класс — и все знают, что эндпоинт принимает. Фронтендер смотрит на DTO, бэкендер пишет логику против него, OpenAPI-схема генерируется автоматически через NelmioApiDocBundle.
Сравни с тем, что я видел (и сам писал) в реальных проектах:
$data = json_decode($request->getContent(), true);
$customerId = $data['customer_id'] ?? null;
$items = $data['items'] ?? [];
// Какой формат items? promoCode вообще есть? Хрен его знает.
Когда твой контракт — это «ну, какой-то массив прилетает», любое изменение что-то ломает неожиданно. Когда контракт — это DTO с типами, PHPStan ловит проблему до прода.
2. Каждый сервис делает одну вещь
В etcd чётко разделены gRPC-сервисы: KV (чтение-запись), Watch (подписка на изменения), Lease (TTL для ключей), Auth (авторизация). Каждый — отдельный интерфейс. Watch не трогает запись. KV не проверяет токены.
В Symfony — та же идея, другие инструменты:
class OrderController
{
#[Route('/orders', methods: ['POST'])]
public function create(
CreateOrderRequest $request,
OrderService $orderService,
): JsonResponse {
return new JsonResponse(
$orderService->create($request)
);
}
}
OrderService создаёт заказы. Не отправляет письма — это NotificationService, слушающий OrderCreatedEvent. Не обрабатывает платежи — это PaymentService.
И альтернатива, которую я регулярно вижу:
class OrderController
{
public function create(Request $request)
{
// 40 строк валидации
// 20 строк авторизации
// 60 строк бизнес-логики
// 15 строк отправки email
// 10 строк логирования
// Итого: 150 строк, не тестируется
}
}
500-строчный god-controller. Все там были. etcd помог мне наконец сформулировать почему это плохо: не потому что «паттерн неправильный», а потому что ты не можешь проследить, что система делает.
3. Middleware собирается как Lego
Каждый gRPC-запрос в etcd проходит через цепочку interceptor’ов: logging → auth → metrics → handler → metrics → response. Каждый interceptor маленький, single-purpose. Сила — в композиции.
В Symfony это ложится на Event Listeners и Messenger Middleware:
class MetricsMiddleware implements MiddlewareInterface
{
public function __construct(
private PrometheusCollector $metrics,
) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$start = microtime(true);
try {
$result = $stack->next()->handle($envelope, $stack);
$this->metrics->increment('messages_processed_total', [
'type' => $envelope->getMessage()::class,
'status' => 'success',
]);
return $result;
} catch (\Throwable $e) {
$this->metrics->increment('messages_processed_total', [
'type' => $envelope->getMessage()::class,
'status' => 'error',
]);
throw $e;
} finally {
$this->metrics->histogram(
'message_duration_seconds',
microtime(true) - $start,
[$envelope->getMessage()::class]
);
}
}
}
Один middleware — одна задача. Метрики здесь, логи там, retry где-то ещё. Цепочка собирается в messenger.yaml.
Антипаттерн — когда у каждого хендлера это руками:
public function handle(CreateOrderCommand $command): void
{
$this->logger->info('Starting order creation...');
$start = microtime(true);
// ... actual logic ...
$this->metrics->record(microtime(true) - $start);
$this->logger->info('Order created');
}
50 хендлеров, 50 копий одного и того же boilerplate. Забыл один — нет метрик. Поменял формат лога — меняй в 50 местах.
4. Observability — это архитектура, а не дополнение
В etcd Prometheus вшит в gRPC-слой с первого дня. Не «добавили через полгода после релиза». Код не считается готовым без метрик.
В PHP:
class PaymentService
{
public function charge(Order $order): PaymentResult
{
$timer = $this->metrics->startTimer('payment_charge_duration');
try {
$result = $this->gateway->process($order);
$this->metrics->increment('payments_total', [
'provider' => $result->provider,
'status' => $result->isSuccess() ? 'success' : 'declined',
]);
return $result;
} catch (GatewayTimeoutException $e) {
$this->metrics->increment('payments_total', [
'provider' => $order->paymentMethod,
'status' => 'timeout',
]);
throw $e;
} finally {
$timer->observe();
}
}
}
Каждый платёж — в метриках. Сколько прошло, сколько таймаутнулось, какой провайдер тормозит. Не потому что кто-то попросил, а потому что без этого ты летишь вслепую.
Помню проект, где прод лежал 40 минут, и единственный способ понять, что происходит, был tail -f /var/log/symfony.log | grep ERROR. Никогда больше.
Пакет: promphp/prometheus_client_php. Пять минут на установку, пятнадцать на подключение Grafana.
5. Снаружи просто, внутри ракетная наука
clientv3 в etcd — это мастер-класс паттерна facade:
client.Put(ctx, "name", "value")
Одна строка. Под капотом: выбор ноды, реконнект при падении, retry с exponential backoff, protobuf-сериализация, Raft-консенсус, запись на диск, подтверждение кворума.
Тот же принцип в PHP:
// Calling code. Простой и понятный.
$paymentService->charge($order);
Внутри charge():
public function charge(Order $order): PaymentResult
{
if ($existing = $this->findExistingPayment($order)) {
return $existing; // idempotency
}
$provider = $this->providerResolver->resolve($order);
$result = $this->withRetry(
fn () => $provider->process($order),
maxAttempts: 3,
backoff: 'exponential',
);
if ($result->isSuccess()) {
$this->fiscalService->createReceipt($order, $result);
}
$this->events->dispatch(new PaymentProcessed($order, $result));
return $result;
}
Контроллер, вызывающий charge(), ничего не знает про фискальные чеки, retry или выбор провайдера. И не должен.
Признак хорошего сервиса: ты можешь объяснить, что он делает, одним предложением — «снимает деньги с клиента за заказ» — а внутри 200 строк аккуратной логики.
6. Путь запроса можно проследить пальцем
В etcd путь запроса читается линейно:
gRPC handler → EtcdServer.Put() → Raft → apply → bbolt (диск)
Никакой магии. Никаких скрытых вызовов. Никаких «откуда это вообще триггерится?».
В Symfony — то же, если не злоупотреблять системой событий:
Request
→ Controller (распаковка DTO)
→ Service (бизнес-логика)
→ Repository (БД)
→ EventDispatcher (побочные эффекты)
→ Response
Открываешь контроллер — видишь, какой сервис зовёт. Открываешь сервис — видишь, что он делает. Открываешь репозиторий — видишь запрос.
Что убивает прозрачность:
@PostPersistна сущности, который втихую отправляет SMSprePersist-листенеры, изменяющие данные до записи, — и ты 30 минут ищешь, кто трогает полеupdatedAt- Десять
EventSubscriber’ов на одном событии с непонятным порядком выполнения
Event-driven — это здорово. Но если новый разработчик не может объяснить «запрос приходит сюда, ответ уходит туда» за 2 минуты — у тебя проблема.
7. Никаких скрытых зависимостей
В etcd все зависимости передаются явно:
func NewKVServer(s *EtcdServer) KVServer { ... }
Видишь конструктор — видишь всё, что нужно классу.
В Symfony — constructor injection, то же самое:
class OrderService
{
public function __construct(
private OrderRepository $orders,
private PaymentGateway $payment,
private EventDispatcherInterface $events,
private LoggerInterface $logger,
) {}
}
Четыре зависимости. Все на виду. Хочешь тестировать? Подсунь моки. Хочешь понять класс? Смотри на конструктор.
Антипаттерны, которые до сих пор живут в природе:
// Service locator: откуда это взялось?
$payment = $this->container->get('payment.gateway');
// Статические вызовы: не тестируется
Cache::put('key', $value);
// new SomeService() внутри другого сервиса: невидимая связность
$validator = new OrderValidator();
Autowiring в Symfony — это не магия в плохом смысле. Контейнер связывает зависимости по типу, но они всё равно видны в конструкторе. Это удобство, а не скрытое поведение.
Мой чек-лист
Изучив etcd, я выжал чек-лист, который теперь применяю к каждому новому сервису:
- Контракт определён? DTO есть, типы заданы, OpenAPI генерится из них
- Контроллер тонкий? Максимум 10 строк, вся логика — в сервисном слое
- Cross-cutting concerns вытащены? Логи, метрики, retry — через middleware, а не copy-paste
- Метрики есть? Если нет — сервис не production-ready
- API снаружи простой? Вызывающий код не знает о внутренней сложности
- Путь запроса прозрачен? Новый разраб находит хендлер за 2 минуты
- Зависимости явные? Всё в конструкторе, ничего из воздуха
Ничего революционного. Базовая гигиена, которую легко забыть под прессом дедлайна.
etcd просто напомнил мне, как выглядит кодбаз, когда эту гигиену не пропустили. И что это возможно даже в большой production-системе.
Какой open-source кодбаз изменил то, как ты пишешь код? Хочется собрать reading list — кидай в комментарии.