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,
) {}
}
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.
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)
);
}
}
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
}
}
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]
);
}
}
}
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');
}
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();
}
}
}
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")
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);
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;
}
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)
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
Open the controller — see which service is called. Open the service — see what it does. Open the repository — see the query.
What kills traceability:
-
@PostPersiston an entity that silently sends SMS -
prePersistlisteners modifying data before writes — and you spend 30 minutes figuring out who's touching theupdatedAtfield - 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 { ... }
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,
) {}
}
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();
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:
- Contract defined? DTOs exist, types are set, OpenAPI generates from them
- Controller thin? 10 lines max, all logic in the service layer
- Cross-cutting concerns extracted? Logging, metrics, retry — through middleware, not copy-paste
- Metrics present? If not, the service isn't production-ready
- Simple API externally? Calling code doesn't know about internal complexity
- Request path traceable? A new developer finds the handler in 2 minutes
- 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)