DEV Community

Cover image for I Studied the etcd Codebase — and It Changed How I Write PHP
Vitalii Cherepanov
Vitalii Cherepanov

Posted on

I Studied the etcd Codebase — and It Changed How I Write PHP

There's a common piece of advice: "Want to write better code? Read good code." Sounds obvious. Rarely practiced.

The problem is that most open-source projects are mazes. You open a repo, see 200 directories, and close the tab. Kubernetes is two million lines. The Linux kernel — don't even think about it. Where do you start?

My answer: etcd.

For those unfamiliar: etcd is a distributed key-value store written in Go. It's the backbone of Kubernetes — every piece of cluster state lives there. But I'm not interested in etcd as a product. I'm interested in it as an example of architecture you can actually read from start to finish.

Here's what surprised me: the principles baked into etcd aren't about Go. They're about software design in general. I work with PHP and Symfony daily, and almost everything I found in etcd translated directly into my projects.

Seven principles, concrete examples, no fluff.


1. One Source of Truth for Your API

In etcd, every API is defined in .proto files. Open rpc.proto and you see all operations: Range, Put, DeleteRange, Txn. Every field is typed. There's no room for "wait, do we accept a string or an integer here?"

In PHP, instead of protobuf, we have strictly typed DTOs:

final readonly class CreateOrderRequest
{
    public function __construct(
        public string $customerId,
        /** @var OrderItemDto[] */
        public array $items,
        public ?string $promoCode = null,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

One class — and everyone knows what the endpoint accepts. The frontend dev looks at the DTO, the backend dev writes logic against it, the OpenAPI schema generates automatically via NelmioApiDocBundle.

Compare this with what I've seen (and written) on real projects:

$data = json_decode($request->getContent(), true);
$customerId = $data['customer_id'] ?? null;
$items = $data['items'] ?? [];
// What's the format of items? Is promoCode a thing? Who knows.
Enter fullscreen mode Exit fullscreen mode

When your contract is "well, some array comes in," any change breaks something unexpected. When your contract is a DTO with types, PHPStan catches the problem before production does.


2. Each Service Does One Thing

etcd has clearly separated gRPC services: KV (read-write), Watch (subscribe to changes), Lease (key TTLs), Auth (authorization). Each one is a separate interface. Watch doesn't touch writes. KV doesn't check tokens.

In Symfony — same idea, different tools:

class OrderController
{
    #[Route('/orders', methods: ['POST'])]
    public function create(
        CreateOrderRequest $request,
        OrderService $orderService,
    ): JsonResponse {
        return new JsonResponse(
            $orderService->create($request)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

OrderService creates orders. It doesn't send emails — that's NotificationService listening to an OrderCreatedEvent. It doesn't process payments — that's PaymentService.

And then there's the alternative I see regularly:

class OrderController
{
    public function create(Request $request)
    {
        // 40 lines of validation
        // 20 lines of authorization
        // 60 lines of business logic
        // 15 lines sending email
        // 10 lines of logging
        // Total: 150 lines, untestable
    }
}
Enter fullscreen mode Exit fullscreen mode

The 500-line god controller. We've all been there. etcd helped me finally articulate why it's bad: not because "the pattern is wrong," but because you can't trace what the system is doing.


3. Middleware Composes Like Lego

Every gRPC request in etcd passes through a chain of interceptors: logging → auth → metrics → handler → metrics → response. Each interceptor is small, single-purpose. The power comes from composition.

In Symfony, this maps to Event Listeners and 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]
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

One middleware, one job. Metrics here, logging there, retry somewhere else. Assemble the chain in messenger.yaml.

The antipattern — when every handler has this manually:

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');
}
Enter fullscreen mode Exit fullscreen mode

50 handlers, 50 copies of the same boilerplate. Forget one — no metrics. Change the log format — change it in 50 places.


4. Observability Is Architecture, Not an Afterthought

In etcd, Prometheus is wired into the gRPC layer from day one. Not "added six months after launch." The code isn't considered done without metrics.

In 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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Every payment — in metrics. How many succeeded, how many timed out, which provider is slow. Not because someone asked for it, but because without it you're flying blind.

I remember a project where production was down for 40 minutes and the only way to understand what was happening was tail -f /var/log/symfony.log | grep ERROR. Never again.

Package: promphp/prometheus_client_php. Five minutes to install, fifteen to wire up Grafana.


5. Simple Outside, Rocket Science Inside

clientv3 in etcd is a masterclass in the facade pattern:

client.Put(ctx, "name", "value")
Enter fullscreen mode Exit fullscreen mode

One line. Under the hood: node selection, reconnection on failure, retry with exponential backoff, protobuf serialization, Raft consensus, disk write, quorum confirmation.

Same principle in PHP:

// Calling code. Simple and clear.
$paymentService->charge($order);
Enter fullscreen mode Exit fullscreen mode

Inside 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;
}
Enter fullscreen mode Exit fullscreen mode

The controller calling charge() knows nothing about fiscal receipts, retries, or provider selection. And it shouldn't.

A sign of a good service: you can explain what it does in one sentence — "charges the customer for an order" — while the implementation is 200 lines of careful logic.


6. You Can Trace a Request With Your Finger

In etcd, the request path reads linearly:

gRPC handler → EtcdServer.Put() → Raft → apply → bbolt (disk)
Enter fullscreen mode Exit fullscreen mode

No magic. No hidden calls. No "where does this even get triggered?"

In Symfony — same thing, if you don't abuse the event system:

Request
  → Controller (unwrap DTO)
    → Service (business logic)
      → Repository (database)
      → EventDispatcher (side effects)
  → Response
Enter fullscreen mode Exit fullscreen mode

Open the controller — see which service is called. Open the service — see what it does. Open the repository — see the query.

What kills traceability:

  • @PostPersist on an entity that silently sends SMS
  • prePersist listeners modifying data before writes — and you spend 30 minutes figuring out who's touching the updatedAt field
  • Ten EventSubscribers on the same event with unclear execution order Event-driven is great. But if a new developer can't explain "request comes in here, response goes out there" within 2 minutes — you have a problem.

7. No Hidden Dependencies

In etcd, all dependencies are passed explicitly:

func NewKVServer(s *EtcdServer) KVServer { ... }
Enter fullscreen mode Exit fullscreen mode

See the constructor — see everything the class needs.

In Symfony — constructor injection, same thing:

class OrderService
{
    public function __construct(
        private OrderRepository $orders,
        private PaymentGateway $payment,
        private EventDispatcherInterface $events,
        private LoggerInterface $logger,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Four dependencies. All visible. Want to test? Swap in mocks. Want to understand the class? Look at the constructor.

Antipatterns that still survive in the wild:

// Service locator: where did this come from?
$payment = $this->container->get('payment.gateway');

// Static calls: untestable
Cache::put('key', $value);

// new SomeService() inside another service: invisible coupling
$validator = new OrderValidator();
Enter fullscreen mode Exit fullscreen mode

Symfony's autowiring isn't magic in the bad sense. The container wires dependencies by type, but you still see them in the constructor. It's convenience, not hidden behavior.


My Checklist

After studying etcd, I distilled a checklist I now apply to every new service:

  1. Contract defined? DTOs exist, types are set, OpenAPI generates from them
  2. Controller thin? 10 lines max, all logic in the service layer
  3. Cross-cutting concerns extracted? Logging, metrics, retry — through middleware, not copy-paste
  4. Metrics present? If not, the service isn't production-ready
  5. Simple API externally? Calling code doesn't know about internal complexity
  6. Request path traceable? A new developer finds the handler in 2 minutes
  7. Dependencies explicit? Everything in the constructor, nothing from thin air None of this is revolutionary. It's basic hygiene that's easy to forget under deadline pressure.

etcd just reminded me what a codebase looks like when that hygiene wasn't skipped. And that it's possible even in a large production system.


What open-source codebase changed how you write code? I'd love to build a reading list — drop yours in the comments.

Top comments (0)