Ürün
Shuffly
Gerçek zamanlı sesli sohbet platformunun arka ucu. İki pazar bu servis üzerinde çalışıyor, uygulamaları ayrı bir ekip geliştiriyor.

Sayfanın devamı için kaydırın
Sistem
İki pazarda gerçek zamanlı sesli odalar çalışıyor, üçüncüsü yolda. Mobil uygulamaları dört kişilik ayrı bir ekip geliştiriyor. Onların çağırdığı API’nin arkasındaki her şey bende: HTTP uygulaması, oda durumunu tutan WebSocket daemon’ları, moderasyon ve ban, satın alma, push ve deploy hattı. Operasyon ekibinin kullandığı operasyon panelini de ben yazıyorum.
Her pazar bir config dosyası, fork değil. Yerelleştirme istemciye kadar iniyor. Yeni bir pazar açmak bir deploy demek.
Sistem
İstemciler
Pazara özel mobil istemciler
Flutter · Dart
İstemci ekibi geliştiriyor
Operasyon paneli
Next.js · TypeScript
Sözleşme
REST ve WebSocket API
OpenAPI · WebSocket
İstemci ekibi bu yüzeyin üzerine geliştiriyor ve arkasını göremiyor. Sistemde sessizce bozulamayacak tek parça burası.
Uygulama
HTTP uygulaması
PHP · Slim
Oda daemon'ı
Workerman
Neden
Tick tabanlı oda durumunu tutuyor: kim hangi koltukta ve ne kadar süredir. REST bu servisle aynı süreci paylaşmıyor, Redis listeleri üzerinden konuşuyor.
Birebir mesaj daemon'ı
Ratchet
Neden
Birebir mesajlaşmayı Ratchet yürütüyor. Koltuk durumunu tutan tick tabanlı oda daemon'ından ayrı duruyor.
Servisler
Sesli odalar
LiveKit
Neden
Yayında kullanıcıya hizmet veren bir sistemde, önceki sağlayıcıdan buraya geçtik. Oturumu, token'ı ve oda durumunu istemciler tuttuğu için uygulamalar da aynı sürümde çıktı.
Moderasyon ve ban
Neden
Baştan sona benim. Raporlama hattı, otomatik kontroller ve ban sistemi; hiçbiri satın alınmadı.
Cüzdan ve satın alma
RevenueCat
Neden
Üçüncü parti. Maaşlı bir ekipte fiş doğrulamasının ve mağaza uç durumlarının bakımını satın almak mantıklı; kendi oyunumda aynı işi kendim yazıyorum, çünkü orada kullanıcı başına fatura bana çıkıyor.
Push bildirimleri
Firebase Cloud Messaging
Durum
İlişkisel veritabanı
MySQL
Önbellek ve mesaj yolu
Redis
6/**7 * Pure breadth-first search over the friend graph. No database access: the8 * caller injects a neighbor provider, so this class can be unit-tested with an9 * in-memory graph. Produces distance-2..maxDepth candidates ranked by BFS10 * distance (closer first), then by bridge count (more mutual paths first).11 *12 * Performance guards are class constants; the constructor accepts overrides so13 * tests can trip the caps cheaply without changing production behaviour.14 */15class FriendGraphTraversal16{17 public const HARD_MAX_DEPTH = 4;18 public const MAX_VISITED_NODES = 5000;19 public const MAX_FRONTIER = 2000;2021 public function __construct(22 private int $maxVisited = self::MAX_VISITED_NODES,23 private int $maxFrontier = self::MAX_FRONTIER,24 ) {25 }2627 public function clampDepth(int $requested): int28 {29 if ($requested < 2) {30 return 2;31 }32 return min($requested, self::HARD_MAX_DEPTH);33 }3435 /**36 * @param list<string> $level1Keys37 * @param callable(list<string>): array<string, list<string>> $neighborProvider38 * @param array<string, true> $excludedKeys39 *40 * @return array{41 * candidates: list<array{userKey: string, distance: int, bridgeCount: int, bridgeKeys: list<string>}>,42 * truncated: bool43 * }44 */45 public function run(46 string $seedKey,47 array $level1Keys,48 callable $neighborProvider,49 array $excludedKeys,50 int $maxDepth,51 ): array {52 $maxDepth = $this->clampDepth($maxDepth);5354 // distance[key] = shortest hop count from seed. seed=0, direct friends=1.55 $distance = [$seedKey => 0];56 foreach ($level1Keys as $k) {57 $distance[$k] = 1;58 }5960 /** @var array<string, array<string, true>> $bridges candidateKey => set of level-1 bridge keys */61 $bridges = [];62 $truncated = false;63 $visited = count($distance);6465 $frontier = array_values(array_unique($level1Keys));66 $d = 1;6768 while ($d < $maxDepth && $frontier !== [] && $visited < $this->maxVisited) {69 if (count($frontier) > $this->maxFrontier) {70 $frontier = array_slice($frontier, 0, $this->maxFrontier);71 $truncated = true;72 }7374 $neighbors = $neighborProvider($frontier);7576 /** @var array<string, array<string, true>> $nextLayer */77 $nextLayer = [];78 foreach ($frontier as $node) {79 // Bridges that reach $node on a shortest path. A level-1 node is80 // its own bridge; deeper nodes carry the accumulated set.81 $nodeBridges = $d === 1 ? [(string) $node => true] : ($bridges[$node] ?? []);82 foreach (($neighbors[$node] ?? []) as $nb) {83 if (isset($distance[$nb]) && $distance[$nb] <= $d) {84 continue; // reached earlier/at this layer — not via $node's next hop85 }86 if (!isset($nextLayer[$nb])) {87 $nextLayer[$nb] = [];88 }89 foreach ($nodeBridges as $b => $_) {90 $nextLayer[$nb][$b] = true; // union accumulates distinct paths91 }92 }93 }9495 $nextFrontier = [];96 foreach ($nextLayer as $nb => $brs) {97 if (isset($distance[$nb])) {98 continue; // committed in an earlier (shorter) layer99 }100 $distance[$nb] = $d + 1;101 $bridges[$nb] = $brs;102 $nextFrontier[] = (string) $nb;103 $visited++;104 if ($visited >= $this->maxVisited) {105 $truncated = true;106 break;107 }108 }109110 $frontier = $nextFrontier;111 $d++;112 }113114 $candidates = [];115 foreach ($bridges as $key => $brs) {116 if (isset($excludedKeys[$key])) {117 continue;118 }119 $bridgeKeys = array_map('strval', array_keys($brs));120 sort($bridgeKeys);121 $candidates[] = [122 'userKey' => (string) $key,123 'distance' => $distance[$key],124 'bridgeCount' => count($brs),125 'bridgeKeys' => $bridgeKeys,126 ];127 }128129 usort($candidates, static function (array $a, array $b): int {130 return [$a['distance'], -$a['bridgeCount'], $a['userKey']]131 <=> [$b['distance'], -$b['bridgeCount'], $b['userKey']];132 });133134 return ['candidates' => $candidates, 'truncated' => $truncated];135 }136}shuffly-serverb7006f04app/Services/Recommendation/FriendGraphTraversal.phpSatır 6 – 136131 satır
/**
* Pure breadth-first search over the friend graph. No database access: the
* caller injects a neighbor provider, so this class can be unit-tested with an
* in-memory graph. Produces distance-2..maxDepth candidates ranked by BFS
* distance (closer first), then by bridge count (more mutual paths first).
*
* Performance guards are class constants; the constructor accepts overrides so
* tests can trip the caps cheaply without changing production behaviour.
*/
class FriendGraphTraversal
{
public const HARD_MAX_DEPTH = 4;
public const MAX_VISITED_NODES = 5000;
public const MAX_FRONTIER = 2000;
public function __construct(
private int $maxVisited = self::MAX_VISITED_NODES,
private int $maxFrontier = self::MAX_FRONTIER,
) {
}
public function clampDepth(int $requested): int
{
if ($requested < 2) {
return 2;
}
return min($requested, self::HARD_MAX_DEPTH);
}
/**
* @param list<string> $level1Keys
* @param callable(list<string>): array<string, list<string>> $neighborProvider
* @param array<string, true> $excludedKeys
*
* @return array{
* candidates: list<array{userKey: string, distance: int, bridgeCount: int, bridgeKeys: list<string>}>,
* truncated: bool
* }
*/
public function run(
string $seedKey,
array $level1Keys,
callable $neighborProvider,
array $excludedKeys,
int $maxDepth,
): array {
$maxDepth = $this->clampDepth($maxDepth);
// distance[key] = shortest hop count from seed. seed=0, direct friends=1.
$distance = [$seedKey => 0];
foreach ($level1Keys as $k) {
$distance[$k] = 1;
}
/** @var array<string, array<string, true>> $bridges candidateKey => set of level-1 bridge keys */
$bridges = [];
$truncated = false;
$visited = count($distance);
$frontier = array_values(array_unique($level1Keys));
$d = 1;
while ($d < $maxDepth && $frontier !== [] && $visited < $this->maxVisited) {
if (count($frontier) > $this->maxFrontier) {
$frontier = array_slice($frontier, 0, $this->maxFrontier);
$truncated = true;
}
$neighbors = $neighborProvider($frontier);
/** @var array<string, array<string, true>> $nextLayer */
$nextLayer = [];
foreach ($frontier as $node) {
// Bridges that reach $node on a shortest path. A level-1 node is
// its own bridge; deeper nodes carry the accumulated set.
$nodeBridges = $d === 1 ? [(string) $node => true] : ($bridges[$node] ?? []);
foreach (($neighbors[$node] ?? []) as $nb) {
if (isset($distance[$nb]) && $distance[$nb] <= $d) {
continue; // reached earlier/at this layer — not via $node's next hop
}
if (!isset($nextLayer[$nb])) {
$nextLayer[$nb] = [];
}
foreach ($nodeBridges as $b => $_) {
$nextLayer[$nb][$b] = true; // union accumulates distinct paths
}
}
}
$nextFrontier = [];
foreach ($nextLayer as $nb => $brs) {
if (isset($distance[$nb])) {
continue; // committed in an earlier (shorter) layer
}
$distance[$nb] = $d + 1;
$bridges[$nb] = $brs;
$nextFrontier[] = (string) $nb;
$visited++;
if ($visited >= $this->maxVisited) {
$truncated = true;
break;
}
}
$frontier = $nextFrontier;
$d++;
}
$candidates = [];
foreach ($bridges as $key => $brs) {
if (isset($excludedKeys[$key])) {
continue;
}
$bridgeKeys = array_map('strval', array_keys($brs));
sort($bridgeKeys);
$candidates[] = [
'userKey' => (string) $key,
'distance' => $distance[$key],
'bridgeCount' => count($brs),
'bridgeKeys' => $bridgeKeys,
];
}
usort($candidates, static function (array $a, array $b): int {
return [$a['distance'], -$a['bridgeCount'], $a['userKey']]
<=> [$b['distance'], -$b['bridgeCount'], $b['userKey']];
});
return ['candidates' => $candidates, 'truncated' => $truncated];
}
}- Analitik sorgular API üzerinden değil doğrudan veritabanından okuyor, böylece raporlama yükü uygulama sunucusuna hiç ulaşmıyor.
Ses motorunu çalışırken değiştirmek
En büyük iş, kullanıcılara hizmet veren bir sistemde gerçek zamanlı ses sağlayıcısını değiştirmekti.
Bu değişiklik arka uçta duramazdı. Oturumu, token’ı ve oda durumunu istemciler tutuyor, yani uygulamaların da aynı sürümde çıkması gerekiyordu. O migration boyunca istemci ekibinin deposuna, onların dalına commit attım. Changelog gönderip beklemedim.
Katkı
- Yazılan commit
- 2.074
- Başka bir ekibin kod tabanında
- 175
- Kaydedilen karar
- 102
23 haftanın 22 tanesi aktif · 6 Nis 2026 – 13 Eyl 2026
En uzun seri · 15 hafta · Haz – Eyl
Dört istemci mühendisinin üzerine geliştirdiği servisi ben sürdürüyorum. Ses sağlayıcısı değişirken onların deposunda, onların dalında, yanlarında çalıştım.
Ölçüm 2026-09-08
Aynı anda platformun neyi sakladığına da karar verdik:
- Birebir mesajlar: saklanıyor. Teslimat garantisi, kapladığı yere değiyor.
- Sesli oda sohbeti: saklanmıyor. Mesaj gövdeleri hiçbir yere yazılmıyor, sistemin en büyük tablosu böylece hiç oluşmuyor.
API’nin kendisi ürün
Dört mühendis bunun üzerine geliştiriyor ve arkasındaki kodu göremiyor. O yüzden API’ye ürün gibi davranıyorum.
- Versiyonlanıyor ve yayınlanıyor; path, şema ve kanal olarak bölünmüş. Merge’de pipeline yayınlıyor, yani okudukları doküman koddan eski olmuyor.
- Her endpoint kendi yan etkilerini yazıyor: iki kez çağrılması güvenli mi, hangi yanıtta ne yapılmalı, hangi hata tekrar denenmeli.
- Entegrasyon rehberleri, tek tek endpoint’leri değil komple akışları anlatıyor.

Sayfanın devamı için kaydırın
Devraldığım stack
PHP 8, Slim 4, MySQL, Redis. WebSocket daemon’ları HTTP uygulamasının yanında duruyor, içinde değil.
Değiştirmeyi erken, hâlâ ucuzken savundum. Karar başka yönde çıktı, iş de şuna döndü: başkalarının rahat çalışabileceği hale getirmek.
53
Alt sistem dokümanı
Hangi parça nasıl davranıyor.
102
Karar kaydı
Neden öyle davranıyor.
4
Test suite
Gerçek MySQL ve Redis üzerinde.
Buradaki asıl hataları mock’lar gizliyor, o yüzden testler gerçeğin üzerinde koşuyor.
Koddan sonrası
- Deploy ve rollback. Dört dal. Prod’a yalnızca testleri geçmiş commit çıkabiliyor, bunu bir tag zorunlu kılıyor.
- Deploy kendini doğruluyor. Reload sonrası WebSocket proxy’sinden gerçek yanıt bekliyor. Bu sunucuda sağlık raporu güvenilir değil.
- Şunları da ben yürütüyorum: moderasyon ve ban, push dağıtımı, staging, CI.