Rate limiting en aplicaciones web: qué proteger antes de elegir una librería

Rate limiting en aplicaciones web: qué proteger antes de elegir una librería

La solución correcta para proteger una ruta de Next.js contra abuso es no empezar por el middleware. Sé que suena raro — todo el mundo busca npm install upstash-ratelimit antes de pensar en qué está protegiendo. Pero esa secuencia garantiza que vas a poner el límite equivocado en el lugar equivocado.

Mi tesis es simple: rate limiting no es una dependencia; es una política de abuso. Y una política requiere decisiones antes de código.

Si alguna vez terminaste ajustando un threshold “a ojo” en producción porque los logs te mostraron falsos positivos, ya viviste este problema. Esta guía es para que no lo repitas.

Rate limiting aplicaciones web Next.js: el orden que casi nadie respeta

La secuencia típica es: leer un tutorial, copiar el middleware, tunear el número hasta que deje de haber quejas. Eso no es una política — es prueba y error sobre usuarios reales.

El orden que sí funciona empieza con cuatro preguntas antes de tocar código:

  1. ¿Qué activo protegés? Un endpoint de login no es lo mismo que una API pública de búsqueda, que no es lo mismo que un webhook entrante.
  2. ¿Qué abuso esperás? ¿Credential stuffing? ¿Scraping? ¿Un bot que llena formularios? El vector esperado determina el patrón de límite.
  3. ¿Cuánto cuesta un falso positivo? Si limitás de más en /api/auth/login, bloqueás usuarios legítimos. Si limitás de menos en /api/send-email, pagás por spam.
  4. ¿Cómo vas a observar que el límite está funcionando? Sin métricas, no hay política — hay esperanza.

OWASP lo plantea claramente en su Authentication Cheat Sheet: los controles defensivos alrededor de autenticación deben incluir lockout progresivo, logging de intentos y distinción entre errores por credencial vs. errores por throttle. No dice “instalá una librería”. Dice “definí el comportamiento esperado y medilo”.

Dónde se equivoca la gente: la receta copiada y su costo oculto

El pattern más común que veo en codebases Next.js es algo así:

// middleware.ts — el clásico "lo copié de la docs"
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  // 10 requests por 10 segundos — ¿por qué 10? "parecía razonable"
  limiter: Ratelimit.slidingWindow(10, "10 s"),
});

export async function middleware(request: NextRequest) {
  const ip = request.ip ?? "127.0.0.1";
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json({ error: "Too Many Requests" }, { status: 429 });
  }
}

El código funciona. El problema no está en el código — está en lo que no está escrito:

Problema 1 — Límite global sin distinción de ruta. Un middleware aplicado a matcher: ["/((?!_next).*)", ] limita igual /api/auth/login que /api/products/search. Son activos con perfiles de abuso completamente distintos.

Problema 2 — IP como única clave. En Argentina (y en cualquier ISP con CGNAT), múltiples usuarios comparten la misma IP pública. Limitar por IP pura significa que el vecino del edificio puede hacerte un “accidental DDoS” a vos sin querer.

Problema 3 — Sin observabilidad. Si success = false devuelve 429 y no hay log, no sabés si estás bloqueando un bot o a tu propio usuario de prueba corriendo tests de integración.

Problema 4 — Sin costo diferencial. Bloquear una búsqueda de producto tiene costo bajo. Bloquear un intento de login legítimo después de un cambio de IP (trabajo → casa → VPN) tiene costo alto. El threshold no puede ser el mismo número.

Esto no es teórico. Es el patrón que aparece cuando buscás “Next.js rate limiting” en GitHub y mirás las primeras diez implementaciones. La mayoría comparten el mismo middleware sin política detrás.

La matriz de decisión: qué mirar antes de escribir una línea

Antes de elegir cualquier implementación — Upstash, express-rate-limit, tu propio contador en Redis o un WAF externo — completá esta matriz para cada endpoint que querés proteger:

┌─────────────────────────┬────────────────┬──────────────────┬────────────────────┬─────────────────────┐
│ Endpoint                │ Activo         │ Abuso esperado   │ Costo FP (falso+)  │ Granularidad clave  │
├─────────────────────────┼────────────────┼──────────────────┼────────────────────┼─────────────────────┤
│ /api/auth/login         │ Cuenta usuario │ Credential stuff │ ALTO — bloqueo real│ IP + username       │
│ /api/contact            │ Bandeja email  │ Spam masivo      │ MEDIO — UX dañada  │ IP + fingerprint    │
│ /api/search             │ BD pública     │ Scraping         │ BAJO — búsqueda    │ IP (con CGNAT warn) │
│ /api/webhooks/incoming  │ Pipeline datos │ Replay attack    │ BAJO — ignorar     │ API key + timestamp │
└─────────────────────────┴────────────────┴──────────────────┴────────────────────┴─────────────────────┘

La columna que más se ignora es Costo FP. Es la que determina si errás para adentro (demasiado permisivo) o para afuera (demasiado restrictivo) — y cuál de los dos es más tolerable para ese activo específico.

Para /api/auth/login, OWASP recomienda explícitamente estrategias de lockout progresivo con notificación al usuario, no un 429 silencioso. Eso requiere lógica de negocio, no solo middleware.

Implementación consciente: cómo se ve una política real en Next.js

Con la matriz en mano, el middleware cambia de forma:

// lib/rate-limit.ts — política explícita por activo
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

// Política diferenciada: cada constante documenta una decisión
export const loginRatelimit = new Ratelimit({
  redis,
  // 5 intentos por minuto por IP+username — basado en OWASP lockout guidance
  // Costo FP alto: preferimos falso negativo antes que bloquear usuario real
  limiter: Ratelimit.fixedWindow(5, "60 s"),
  analytics: true, // observabilidad habilitada — no negociable
});

export const searchRatelimit = new Ratelimit({
  redis,
  // 100 req/10s por IP — costo FP bajo, margen más ancho
  limiter: Ratelimit.slidingWindow(100, "10 s"),
  analytics: true,
});
// app/api/auth/login/route.ts — política aplicada con contexto
import { loginRatelimit } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const body = await request.json();
  const username = body?.username ?? "anon";
  const ip = request.ip ?? "unknown";

  // Clave compuesta: IP + username evita el problema de CGNAT
  // Un usuario en CGNAT compartido no afecta a otros usuarios distintos
  const identifier = `login:${ip}:${username}`;

  const { success, limit, remaining, reset } = await loginRatelimit.limit(identifier);

  if (!success) {
    // Log explícito: sin esto no hay política, hay esperanza
    console.warn(`[rate-limit] LOGIN bloqueado — identifier: ${identifier}, reset: ${reset}`);

    return NextResponse.json(
      {
        error: "Demasiados intentos. Intentá nuevamente en unos minutos.",
        // No exponer reset exacto en producción — información útil para atacantes
      },
      {
        status: 429,
        headers: {
          "Retry-After": String(Math.ceil((reset - Date.now()) / 1000)),
        },
      }
    );
  }

  // ... lógica de autenticación
}

Dos diferencias críticas respecto al middleware genérico: la clave es compuesta (no solo IP) y cada rechazo genera un log. Sin log, no hay feedback para ajustar la política.

Si usás Railway para deployar — que es mi stack actual para proyectos Next.js — los logs del console.warn van directo al dashboard de Railway sin configuración extra. Es suficiente para empezar a ver patrones antes de necesitar algo más sofisticado.

Límites de esta guía: qué no podés concluir sin tus propios datos

Esto es importante y no lo voy a enterrar al final: los números de esta guía son puntos de partida, no valores validados para tu caso.

No sabés si 5 intentos por minuto es el threshold correcto para login hasta que:

  • Medís la distribución real de intentos de usuarios legítimos en tu app (un usuario que olvidó la contraseña puede intentar 3-4 veces en 30 segundos)
  • Observás cuántos 429 genera el límite en la primera semana
  • Revisás si hay tests de integración o health checks que disparen el mismo endpoint

Sin esos datos, cualquier número que elijas — incluyendo los de este post — es una estimación educada. El objetivo del post no es darte el threshold; es que sepas qué preguntas hacerte antes de fijarlo.

Lo mismo aplica para la elección de librería. Upstash funciona bien con Next.js en Edge Runtime porque Redis opera fuera del bundle. Pero si ya tenés Redis propio en Railway, un wrapper simple con ioredis puede ser suficiente. La decisión depende de tu infraestructura, no de un benchmark universal.

Si te interesa cómo conectar observabilidad más profunda en Next.js, el post de OpenTelemetry en Spring Boot donde los logs dicen OK y los traces muestran el problema tiene el mismo principio: sin traza, el diagnóstico es adivinanza.

Errores comunes que convierten una política en ruido

Error 1 — Rate limiting sin header Retry-After. RFC 6585 especifica que un 429 debería incluir Retry-After. Sin él, el cliente (o el browser) puede reintentar inmediatamente y amplificar la carga. Ya cubrí este patrón en el post de Retry no es gratis: el costo del reintento no aparece en el p95 hasta que ya es tarde.

Error 2 — Aplicar rate limiting en el cliente. Veo esto ocasionalmente: throttle en el frontend para “no sobrecargar la API”. El cliente no es una frontera de seguridad. Cualquier persona con curl la saltea.

Error 3 — Confundir rate limiting con autenticación. Un límite de 429 no reemplaza validación de credenciales, tokens ni autorización. Reduce la superficie de ataque en el tiempo, pero no autentica nada. Son capas distintas, no alternativas.

Error 4 — Ignorar el efecto de CDN/proxy. Si tu Next.js está detrás de Vercel Edge, Cloudflare o un nginx, request.ip puede devolver la IP del proxy, no del cliente real. Necesitás leer X-Forwarded-For con cuidado — y validar que el header no pueda ser falsificado desde el cliente.

// Extraer IP real con conciencia del stack
function getClientIp(request: NextRequest): string {
  // X-Forwarded-For puede tener múltiples valores: "client, proxy1, proxy2"
  // El primero es el cliente real — pero solo si confiás en el proxy que lo setea
  const forwarded = request.headers.get("x-forwarded-for");
  if (forwarded) {
    return forwarded.split(",")[0].trim();
  }
  return request.ip ?? "unknown";
}

FAQ: preguntas reales sobre rate limiting en Next.js

¿Necesito Redis para rate limiting en Next.js?
No para casos simples, pero sí para cualquier deploy con más de una instancia. En-memoria no funciona cuando hay múltiples réplicas porque cada instancia tiene su propio contador. Si usás Railway con un solo container, en-memoria puede alcanzar para empezar — pero es una deuda técnica visible.

¿Cuál es la diferencia entre rate limiting y throttling?
Rate limiting rechaza requests que superan un umbral (429 Too Many Requests). Throttling los encola o los ralentiza sin rechazarlos. Para protección contra abuso, rate limiting es más predecible. Throttling tiene su lugar en colas de procesamiento, no en APIs públicas.

¿Debo poner el rate limiting en middleware o en cada route handler?
Depende de la granularidad que necesitás. Middleware global es conveniente pero aplica la misma política a todo. Route handler te da control fino por activo. La matriz de decisión de más arriba debería guiar esa elección — si todos tus activos tienen el mismo perfil de abuso, el middleware está bien.

¿Qué pasa con los bots que rotan IPs?
IP-based rate limiting sola no alcanza contra bots sofisticados con rotación de IP. Para ese vector, necesitás fingerprinting de browser (TLS JA3, user-agent patterns, behavior analysis) o un WAF dedicado. Es un scope diferente al de este post — y honestamente, si llegaste a ese problema, necesitás más que una librería de Node.

¿Upstash Ratelimit es la única opción para Next.js en Edge Runtime?
No. Upstash funciona bien porque su cliente Redis es HTTP-based y compatible con Edge. Pero también podés usar @vercel/kv si estás en Vercel, o un worker de Cloudflare con KV si usás Cloudflare Workers. La restricción técnica es que Edge Runtime no soporta sockets TCP — cualquier solución tiene que hablar HTTP para el almacenamiento.

¿Cómo sé si mi rate limit está bien calibrado?
Mirá la distribución de 429 en los primeros 7 días de activación. Si los 429 vienen de IPs/identificadores únicos que nunca viste antes → el límite está capturando abuso. Si los 429 vienen de IPs recurrentes que también tienen requests exitosos → posible falso positivo. Sin analytics activado en la librería y sin logs, no podés responder esta pregunta.

Mi postura: la librería es un detalle de implementación

Rate limiting en aplicaciones web con Next.js es un tema donde el 80% del trabajo es decisión técnica y el 20% es código. Casi toda la literatura hace lo inverso.

No compro el argumento de que “cualquier rate limiting es mejor que ninguno”. Un límite mal calibrado sobre un endpoint de login puede bloquear usuarios legítimos de forma sistemática — y ese daño es medible y silencioso si no tenés observabilidad.

Lo que sí compro: definir la política antes del código obliga a hacerse preguntas que el middleware genérico no te hace. ¿Qué activo? ¿Qué abuso? ¿Qué costo si me equivoco para afuera? Esas tres preguntas cambian el threshold, la granularidad de la clave y el comportamiento ante el rechazo.

El próximo paso concreto: tomá el endpoint más crítico de tu app (probablemente login o registro), completá la fila de la matriz para ese activo, y recién ahí escribí el límite. Si querés ver cómo aplicar el mismo pensamiento a Server Actions con Prisma, el post de Prisma Server Actions en Next.js y el N+1 que aparece cuando no lo esperás tiene el mismo patrón: diagnóstico antes de solución.

Y si el endpoint que protegés maneja datos sensibles, el post sobre useEffect y sincronización de estado en React 19 es un recordatorio de que las abstracciones que simplifican también pueden esconder comportamiento inesperado — aplica igual para middleware.

Fuente original:

Este artículo fue publicado originalmente en juanchi.dev

What Are Lakehouse Catalogs? The Role of Catalogs in Apache Iceberg

The three core responsibilities of a lakehouse catalog

This is Part 7 of a 15-part Apache Iceberg Masterclass. Part 6 covered the write process and explained how the catalog enables atomic commits. This article covers what catalogs are, why they matter, and how to choose between the many options available in 2026.

A lakehouse catalog is the component that answers one question: “Where is the current metadata for this table?” Without a catalog, every engine would need to independently locate and track metadata files. With a catalog, there is a single source of truth that coordinates reads, writes, and access control across all engines.

Table of Contents

  1. What Are Table Formats and Why Were They Needed?
  2. The Metadata Structure of Current Table Formats
  3. Performance and Apache Iceberg’s Metadata
  4. Technical Deep Dive on Partition Evolution
  5. Technical Deep Dive on Hidden Partitioning
  6. Writing to an Apache Iceberg Table
  7. What Are Lakehouse Catalogs?
  8. Embedded Catalogs: S3 Tables and MinIO AI Stor
  9. How Iceberg Table Storage Degrades Over Time
  10. Maintaining Apache Iceberg Tables
  11. Apache Iceberg Metadata Tables
  12. Using Iceberg with Python and MPP Engines
  13. Streaming Data into Apache Iceberg Tables
  14. Hands-On with Iceberg Using Dremio Cloud
  15. Migrating to Apache Iceberg

What a Catalog Does

The three core responsibilities of a lakehouse catalog

Every Iceberg catalog performs three functions:

Store the current metadata pointer. When a query engine asks for table analytics.orders, the catalog returns the location of the current metadata.json file (e.g., s3://warehouse/orders/metadata/v42.metadata.json). This is the most fundamental responsibility. As described in Part 6, the atomic update of this pointer is what makes ACID transactions possible.

Manage namespaces. Catalogs organize tables into hierarchical namespaces (databases, schemas). This provides logical organization (production.analytics.orders vs staging.analytics.orders) and is the foundation for access control.

Enforce access control. Catalogs determine which users and engines can read, write, or manage specific tables and namespaces. This ranges from simple table-level permissions to fine-grained column-level and row-level security.

The REST Catalog Protocol

How the Iceberg REST Catalog Protocol works from table load through atomic commit

The Iceberg REST Catalog specification defines a standard HTTP API for catalog operations. This protocol has become the industry standard because it decouples the catalog implementation from the engine.

The key operations:

The key operations

The protocol includes credential vending: the catalog returns short-lived storage credentials alongside the metadata location, so the engine can access the data files directly without needing permanent storage credentials. This is important for multi-tenant environments where Dremio and other engines need scoped access to specific tables.

Why the REST Protocol Matters

Before the REST catalog specification, every engine needed a custom integration for each catalog type. Spark had its own Hive Metastore connector, Trino had a different one, and adding a new catalog meant updating every engine. The REST protocol standardizes this: any engine that speaks REST can talk to any catalog that implements the specification.

This is what makes the Iceberg ecosystem genuinely multi-engine. You can use Spark for ETL, Dremio for interactive analytics, Trino for exploration, and Flink for streaming, all pointed at the same REST catalog. Each engine sees the same tables, the same schemas, and the same snapshots.

Multi-Engine Coordination

When multiple engines share a catalog, the catalog becomes the coordination point for concurrent access. The atomic compare-and-swap mechanism ensures that two engines writing to the same table cannot corrupt each other’s commits. This is fundamentally different from file-system-based metastores where coordination relies on file renames that may not be atomic on object storage.

Governance Portability

One of the biggest concerns in the catalog landscape is governance portability. Access control policies (who can query what) are defined in the catalog, but there is no industry standard for sharing these policies across catalogs. If you set up row-level security in one catalog, that policy does not automatically transfer to another.

This is why many architects recommend picking a catalog that will serve as the single governance boundary and having all engines connect through it, rather than having multiple catalogs with duplicate governance rules.

The Catalog Landscape

Open source vs managed catalog options in the 2026 lakehouse ecosystem

Open Source Catalogs

Apache Polaris (Apache Incubating). The leading vendor-neutral REST catalog implementation. Co-created by Snowflake and Dremio, Polaris is designed to be engine-agnostic and cloud-agnostic. It implements the full REST Catalog spec with fine-grained access control and credential vending. It is the foundation for Dremio’s Open Catalog.

Nessie. Differentiates itself with Git-like branching and merging for data. You can create branches, make changes to multiple tables, and merge them atomically. This is useful for testing pipeline changes or implementing multi-table transactions.

Unity Catalog OSS. Databricks’ open-source catalog offering. It provides multi-format support (Delta, Iceberg, Hudi) and includes AI/ML asset management (models, features). Closely tied to the Databricks ecosystem.

Lakekeeper. A lightweight, Rust-native REST catalog implementation focused on performance and minimal operational footprint. Good for teams that want a self-hosted catalog without the complexity of larger platforms.

Apache Gravitino (Incubating). A federation-focused catalog that can bridge multiple underlying catalogs and storage systems. Designed for organizations that need a unified metadata view across multiple Iceberg catalogs, Hive metastores, and other data sources.

Managed Catalogs

Dremio Open Catalog. A managed Polaris-based catalog that includes automatic table optimization (compaction, snapshot expiry, orphan cleanup) and integrates with Dremio’s query engine, semantic layer, and AI capabilities.

AWS Glue. Amazon’s managed metastore service. Widely used because it is integrated with the AWS ecosystem (Athena, EMR, Redshift Spectrum). Supports Iceberg tables natively and acts as both a Hive-compatible metastore and an Iceberg catalog.

Databricks Unity Catalog (managed). The enterprise version of Unity Catalog with additional governance features, lineage tracking, and AI asset management. Tightly integrated with the Databricks runtime.

Snowflake Horizon. Snowflake’s catalog and governance layer that supports Iceberg tables in Snowflake-managed storage.

Google BigLake. Google Cloud’s managed metadata service for Iceberg tables on GCS.

Microsoft OneLake. Microsoft’s unified storage and catalog layer within the Fabric ecosystem.

How to Choose a Catalog

The decision depends on three factors:

The decision depends on three factors

The safest long-term choice is a REST-catalog-compatible implementation because every major engine supports the protocol. If you start with a REST catalog, you can swap implementations later without changing your engine configurations.

Catalogs Are Not Optional

Some teams try to use Iceberg without a proper catalog, relying on Hadoop-style file system catalogs that use file renames for atomicity. This works on HDFS but is unreliable on object storage (S3 does not support atomic renames). For production lakehouses on cloud storage, a proper catalog with server-side compare-and-swap is essential.

Part 8 covers a newer approach where the catalog is embedded directly in the storage layer.

Books to Go Deeper

Free Resources

What It Really Takes to Become a Senior Software Engineer

I have always dreamed of becoming a Senior Software Engineer, and throughout my career I have realised that it requires far more than just technical ability. Currently, I work as a Software Engineer III at LexisNexis Risk Solutions, and I took the opportunity to gather advice and perspectives from engineers, managers, product, architecture, and leadership across the organisation.

Thanks to everyone who took the time to share their thoughts, experiences, and insights.

What stood out most was how consistent the advice was. Different people, different roles, but very similar themes. This post is a compilation of those common patterns.

Core Mindset

Be Self-Directed

Senior engineers are expected to operate with autonomy.

  • Take ambiguous problems and define the path forward
  • Fill in missing details yourself and continue to validate
  • Work independently without constant guidance
  • Take ownership and run with projects
  • Be proactive rather than waiting for tasks

The shift to senior level is largely about moving from “being assigned work” to “owning problems.”

Think Bigger Than the Task

  • Think in systems and larger building blocks
  • Understand product goals and business context
  • Learn the language of the product
  • Consider trajectory, tradeoffs, and long-term impact
  • Not all solutions are equal — think carefully before acting

A strong theme was that seniors don’t just solve problems — they evaluate the quality and impact of solutions.

Be Curious and Experimental

Growth comes from exploration.

  • Dig deeper into problems
  • Try things and experiment
  • Learn by building
  • Don’t be afraid to make mistakes
  • Figure things out yourself before escalating

Curiosity and willingness to explore came up repeatedly as key growth drivers.

Technical Growth

Build Strong Technical Foundations

Technical excellence still matters.

  • Develop strong engineering fundamentals
  • Improve your system knowledge
  • Become knowledgeable in a specific domain
  • Build subject matter expertise
  • Understand infrastructure, observability, dashboards, alerts, and pipelines

Technical strength is still required — but it needs to be reliable and deep.

Keep Upskilling

Software changes constantly — adaptation is part of the job.

  • Take courses
  • Study good codebases
  • Fork and learn from GitHub/open-source projects
  • Stay technically passionate
  • Keep improving your engineering craft

Engineering Excellence

Focus on quality and ownership.

  • Don’t just “make it work”
  • Catch issues early
  • Monitor systems and visibility
  • Maintain high standards
  • Be methodical and thoughtful
  • Learn when to slow down and think carefully

Communication & Collaboration

Communication Is a Superpower

Nearly everyone highlighted communication.

  • Ask questions
  • Share feedback
  • Explain concepts clearly
  • Adapt explanations to different audiences
  • Present ideas confidently
  • Talk through technical decisions
  • Keep managers informed about concerns and goals

Strong communication builds trust and influence.

Help Others Grow

Senior engineers multiply the effectiveness of the team.

  • Mentor juniors
  • Share knowledge openly
  • Review PRs thoughtfully
  • Teach along the way
  • Be approachable and helpful

“When you can explain things clearly to people below and above your level, that’s valuable.”

Work Well With People

Technical skill alone is not enough.

  • People matter a lot
  • Everyone is a peer
  • Collaborate effectively
  • Learn from seniors and peers
  • Avoid negativity and unnecessary criticism
  • Pick your battles

A recurring theme was maturity and professionalism.

Ownership & Visibility

Take Initiative

  • Volunteer for projects
  • Speak up when something should improve
  • Create momentum
  • Don’t shy away from responsibility
  • Own outcomes, not just tasks

Opportunity doesn’t always come directly — you often need to step into it.

Make Your Work Visible

Good work should be discoverable.

  • Track your changes and contributions
  • Create PBIs/tasks for your work
  • Participate actively in PR discussions
  • Document your work clearly
  • Contribute to visible projects

Visibility helps others understand your impact.

Product & Business Awareness

Understand the “Why”

Senior engineers connect technical decisions to business outcomes.

  • Understand customer and product needs
  • Solve meaningful problems
  • Think beyond implementation
  • Look for opportunities to innovate
  • Balance engineering with practicality

Better decisions come from understanding context, not just code.

Career Growth Advice

Be Intentional About Your Career

Growth does not happen automatically.

  • Understand your manager’s expectations
  • Set objectives aligned to your career goals
  • Look at strong examples around you
  • Ask directly for opportunities and feedback
  • Advocate for yourself
  • Don’t say “yes” to everything if you are unhappy

Final Thoughts

Being a senior engineer is not just about writing better code.

It is about:

  • owning problems,
  • communicating clearly,
  • helping others succeed,
  • understanding the bigger picture,
  • and consistently demonstrating maturity, initiative, and reliability.

Technical skill opens the door, but leadership, communication, and ownership are what truly distinguish senior engineers.

… and a final gem from our recently retired Principal Engineer: “Don’t forget to stay healthy, lift weights”

Microservices Were Never About Technology

Every failed microservices adoption I have seen made the same mistake: treating microservices as an infrastructure pattern instead of an organisational one. The technology is the easy part. The hard part is everything else.

The microservices conversation in most engineering teams goes something
like this. The monolith is getting unwieldy. Deployments are slow.
The codebase is hard to navigate. A senior engineer proposes breaking
things apart into services. The team agrees. They spend six months
doing it. Things get worse.

The services are too small or too large. Nobody agrees on where the
boundaries should be. Simple features now require coordinating changes
across three repositories. A bug that used to take twenty minutes to
debug now takes two hours because it crosses service boundaries.
Deployments are more frequent but individually more fragile. The team
is working harder than before and moving slower than before.

They blame the technology. The technology is not the problem.

Microservices failed for them for the same reason they fail for most
teams that adopt them: the team treated decomposition as a technical
decision and ignored the organisational reality that microservices
are actually designed to solve. You cannot separate the architecture
from the team structure. Conway’s Law is not optional.

What microservices were actually invented for

Amazon is the origin story most people know. The mandate from Jeff
Bezos in the early 2000s: all teams will expose their data and
functionality through service interfaces. All teams will communicate
through these interfaces. No other form of interprocess communication
is allowed. Anyone who doesn’t do this will be fired. He was serious.

The problem Bezos was solving was not technical. Amazon’s engineering
organisation had grown to a size where teams were deeply entangled with
each other. Team A could not deploy without coordinating with Team B,
which needed sign-off from Team C, which had a dependency on Team D.
Every change required a synchronisation meeting. Every release was a
negotiation. The coupling between teams was strangling the organisation’s
ability to move.

Services were the solution because they forced a contract between teams.
If Team A owns Service A, and Team B owns Service B, and they communicate
only through a defined API, then Team A can change anything inside
Service A without asking Team B’s permission. Team B can deploy Service B
on its own schedule. The organisational autonomy is enforced by the
technical boundary.

This is the thing that most microservices adoptions miss entirely.
Services are not primarily a way to scale technology. They are a way
to scale teams. The technical properties of services (independent
deployment, technology flexibility, fault isolation) are valuable
side effects of the organisational property (team autonomy).

If you adopt microservices without the organisational changes that
make them valuable, you get all the costs of distributed systems
and none of the benefits.

The cost of distribution

A monolith, for all its problems, has properties that distributed
systems do not have and cannot have.

A function call inside a monolith is reliable. Either it works or
it throws an exception. It is fast. It completes in microseconds.
It participates in the same database transaction as the code that
called it. If the whole operation needs to be rolled back, it is.

A network call between services is unreliable. It might succeed.
It might fail. It might succeed on the server side and fail on the
network before the response reaches the caller. It might time out,
leaving you with no information about whether the remote operation
completed. It is slow relative to a function call. It crosses
a transaction boundary, which means if something fails after the
call succeeded, you have a consistency problem that cannot be
resolved by a rollback.

This is not an implementation detail to be engineered around. It is
a fundamental property of distributed systems, described precisely
in the fallacies of distributed computing that Peter Deutsch wrote
in 1994 and that the industry has been rediscovering ever since.

The network is not reliable. Latency is not zero. Bandwidth is not
infinite. The network is not secure. Topology changes. There is not
one administrator. Transport cost is not zero. The network is not
homogeneous.

Every service boundary you add to your system is a place where these
fallacies apply. Every service call is an opportunity for latency,
failure, and consistency problems that simply do not exist inside
a monolith. The question is whether the organisational benefits of
the boundary justify the distributed systems cost of maintaining it.

For a team of eight people working on one product, they almost never do.

Where the boundaries actually belong

The most common failure mode in microservices adoption is drawing
service boundaries around technical concerns rather than business
ones. Teams create an “auth service,” a “notification service,”
a “user service,” a “payment service.” These feel like natural
decompositions because they map to recognisable technical concepts.

They are terrible service boundaries.

An auth service that every other service must call to validate a
token is not a service. It is a shared library that has been deployed
as infrastructure, adding network latency and a failure mode to every
authenticated request in the system. If the auth service is slow,
everything is slow. If the auth service is down, everything is down.
You have taken a piece of logic that could live as a function call
and made it a distributed systems problem.

A notification service is not a service. It is a collection of side
effects that have been externalized, creating a situation where the
service that wants to send an email must make a network call, handle
the failure case, and figure out what to do if the notification
service is unavailable at the moment the email needs to be sent.

The boundaries that work are the ones that map to bounded contexts
in the business domain. Not “the thing that handles auth” but “the
thing that owns everything about how customers interact with our
platform.” Not “the thing that sends notifications” but “the thing
that owns the customer communication history and all the rules about
when and how to communicate.”

These boundaries are harder to identify. They require understanding
the business deeply enough to know where the real seams are. They
require conversations with product managers and domain experts, not
just with engineers. They change as the business evolves. But they
are the boundaries that, when you respect them, produce services
that teams can own autonomously and evolve independently.

Domain-Driven Design’s concept of bounded contexts is the clearest
framework for finding these boundaries. The bounded context defines
the scope within which a particular domain model applies. At the
edge of the bounded context, the model changes. That is where the
service boundary belongs.

# A service boundary drawn around a technical concern.
# Every other service calls this. Auth is now a distributed dependency.
#
# Bad:
class AuthService:
    async def validate_token(self, token: str) -> User:
        ...
    async def create_token(self, user_id: str) -> str:
        ...
    async def revoke_token(self, token: str) -> None:
        ...


# A service boundary drawn around a business capability.
# This service owns everything about an order, including its auth context.
# Other services don't call into it for auth. They communicate
# through events when they need to know something happened.
#
# Better:
class OrderService:
    async def place_order(self, customer_id: str, items: list) -> Order:
        # Auth context is resolved here, not farmed out to a network call
        customer = await self.customer_repository.get(customer_id)
        if not customer.can_place_orders():
            raise InsufficientPermissionsError()
        ...

    async def cancel_order(self, order_id: str, requesting_customer_id: str) -> None:
        order = await self.order_repository.get(order_id)
        if order.customer_id != requesting_customer_id:
            raise InsufficientPermissionsError()
        ...

Conway’s Law is a constraint, not a suggestion

Mel Conway observed in 1967 that organisations produce systems that
mirror their communication structures. A team with three groups will
produce a system with three components. This is not because they
planned to. It is because the system reflects who talks to whom.

The implication that most teams don’t fully absorb: if you want a
particular system architecture, you need the corresponding
organisational structure. You cannot have a microservices architecture
with a team structure designed for a monolith. The organisation will
fight the architecture until one of them wins, and the organisation
usually wins because it existed first.

This is why Amazon’s microservices worked. The service boundaries
and the team boundaries were the same boundaries. Team A owns Service A.
Not “Team A and Team B both contribute to Service A.” Not “Service A
is maintained by whoever has time.” One team, one service, full ownership.
The organisational autonomy and the technical autonomy were the same thing.

Most microservices adoptions separate these. The same team that used
to work on the monolith now works on six services. They have all the
coordination overhead of distributed systems and none of the team
autonomy that makes it worth it. They still talk to each other constantly
because they’re the same people. The service boundaries don’t reflect
team boundaries because there are no team boundaries. There is one team
doing distributed systems for no organisational reason.

The inverse Conway maneuver, a term coined by Thoughtworks, is the
deliberate version: you design the team structure you want, then
let the architecture follow from it. If you want a payments service
that can be developed and deployed independently, you need a payments
team that can make decisions and ship code independently. If you do
not have or cannot create that team, you do not have the prerequisite
for the payments service.
The prerequisite check before splitting a service:

Who will own this service?
“The backend team” is not an answer.
A named, stable, small team is an answer.
Can that team deploy the service without coordinating with
other teams?
If not, the boundary is wrong or the ownership is wrong.
Can that team change the service’s internal implementation
without changing any other service?
If not, the boundary is wrong.
Is there a defined contract (API, event schema) between this
service and its consumers?
If not, you don’t have a service. You have a distributed module.
Does the team have enough context about the business domain
this service represents to make good decisions autonomously?
If not, the team needs to exist and stabilise before the service
should be extracted.

If any of these answers is no, the split is premature.

The operational surface nobody accounts for

When a team decides to split their monolith into ten services, they
usually have a plan for the technical decomposition. They rarely have
a plan for what they are about to own operationally.

A monolith has one deployment pipeline. One set of infrastructure
to configure. One place to look at logs. One set of metrics. One
runbook for when things go wrong. The operational complexity is low.

Ten services have ten deployment pipelines. Ten infrastructure
configurations. Log aggregation that spans services. Distributed
tracing to follow a request through multiple services. Ten runbooks,
except the incidents that matter will involve multiple services and
none of the runbooks will cover that. Service discovery. Health
checking at the inter-service level. Circuit breakers for when
downstream services are degraded.

None of this complexity is impossible to manage. It is all solvable.
But it requires a team that has the capacity to manage it, tools
that have been set up before the split happens, and expertise that
takes time to develop.

Most teams split their services and then build the operational
infrastructure retroactively, while also trying to deliver product
work, while also debugging the new distributed systems problems they
did not have before. This is where the eighteen months of slowdown
comes from.

The teams that do this well build the operational infrastructure
first. They get distributed tracing working in the monolith before
they split it. They standardise their deployment pipeline before
they have ten of them. They establish logging conventions before
they have ten services emitting logs in subtly different formats.

# The operational baseline that must exist before splitting services.
# This is not optional infrastructure to add later.

# Centralised structured logging
logging:
  format: json
  fields:
    service: ${SERVICE_NAME}
    version: ${SERVICE_VERSION}
    environment: ${ENVIRONMENT}
    trace_id: ${TRACE_ID}    # Must be propagated across service calls
    span_id: ${SPAN_ID}

# Every service exposes these endpoints. No exceptions.
health_endpoints:
  liveness: /healthz      # Is the process running?
  readiness: /ready       # Is it ready to serve traffic?
  metrics: /metrics       # Prometheus metrics

# Every inter-service call propagates these headers
trace_propagation:
  headers:
    - traceparent           # W3C Trace Context
    - tracestate

# Every service has these alerts configured before it handles traffic
minimum_alerts:
  - error_rate_above_1_percent
  - p99_latency_above_1_second
  - service_unavailable

The monolith that should stay a monolith

Not every system should be microservices. This is easy to say and
hard to accept in an industry where microservices became the mark
of a serious engineering organisation.

The monolith that should stay a monolith is the one where:

The team is small enough that coordination overhead is low. Five
to eight engineers can coordinate in a daily standup without the
synchronisation cost becoming significant. For a team this size,
the organisational problem that microservices solve does not exist.

The domain is not yet well understood. Early-stage products have
unstable domain models. The concepts that seem fundamental change
as you learn what you’re actually building. Service boundaries drawn
around an unstable domain model have to be redrawn as the domain
stabilises, which is expensive and demoralising. The monolith lets
the domain model evolve cheaply. Split when the domain is understood.

The operational team does not exist. If nobody owns the infrastructure
that a distributed system requires, the system will be operated badly.
A well-operated monolith beats a poorly-operated distributed system
every time.

The internal structure can be improved without splitting. A modular
monolith with clear internal boundaries, enforced through package
structure and dependency rules, provides most of the cognitive benefits
of microservices (clear ownership, bounded contexts, interface discipline)
without the distributed systems cost. It is not a compromise. For the
right team and domain, it is the correct architecture.

# A modular monolith with enforced boundaries.
# orders/ cannot import directly from payments/.
# They communicate through defined interfaces.
# This is achievable without distributed systems.

# src/orders/service.py
from orders.repository import OrderRepository
from orders.events import OrderPlaced  # Orders emits events
# from payments.service import PaymentService  # This import is forbidden
                                               # enforced by linting rules

class OrderService:
    def __init__(
        self,
        repository: OrderRepository,
        event_bus: EventBus,
        payment_gateway: PaymentGateway,  # Interface, not concrete payments module
    ):
        self.repository = repository
        self.event_bus = event_bus
        self.payment_gateway = payment_gateway

    async def place_order(self, customer_id: str, items: list) -> Order:
        order = Order.create(customer_id=customer_id, items=items)
        await self.repository.save(order)
        await self.event_bus.publish(OrderPlaced(order_id=order.id))
        return order

# The payments module listens for OrderPlaced events.
# It never gets called directly by orders.
# The boundary is real. It is enforced by design, not by a network.

# src/payments/handlers.py
from orders.events import OrderPlaced  # Reading event schema is allowed

class PaymentEventHandler:
    async def on_order_placed(self, event: OrderPlaced) -> None:
        await self.payment_service.initiate_payment(order_id=event.order_id)

This is a real architecture that scales further than most teams
think before the overhead of splitting services becomes worth paying.
Shopify ran a version of this for years. Stack Overflow still does.
They are not unsophisticated organisations.

What the good teams understand

The teams that have figured out distributed systems share a
perspective that took most of them several years and at least one
failed microservices adoption to arrive at.

Services are not about code organisation. They are about team
organisation. A service boundary that does not correspond to a team
boundary is overhead without benefit.

The overhead of distributed systems is real, permanent, and
compounding. You pay it forever. It needs to buy something worth
having. For a team that is too large to coordinate, team autonomy
is worth having. For a team that is not yet at that size, it is
not.

The correct direction of reasoning is: we have an organisational
problem, what architecture solves it? Not: we have an architecture
trend, what organisation do we need to adopt it?

Microservices adopted as a technical decision produce the costs
of distribution and the politics of boundary negotiation without
the autonomy that makes them valuable. Microservices adopted as
an organisational decision, by teams that have done the work of
defining ownership and building operational foundations, produce
systems that actually deliver what the pattern promises.

The technology has never been the hard part.
The hard part is everything the technology forces you to sort out first.
Most teams skip that part and wonder why the technology failed them.

JS Crime Scene: The Misleading Array

🕵️ JS Crime Scene: The Case of the Misleading Array

📹 Surveillance Footage

The full investigation is available on TikTok:

View the Evidence

The call came in early.

An application was completely misbehaving, data was leaking out of order, and the logs were a mess.

The victim?

Developer Sanity

Someone tried to sort a simple array of integers, and JavaScript decided to play by its own rules.

Let’s inspect the evidence, identify the culprit, and deploy the one-line fix before production collapses entirely.

🔍 The Crime Scene Report

We found a collection of numerical values at the center of the incident.

No complex objects.

No deeply nested structures.

Just raw numbers.

Here is the exact code executed at the scene:

// The Lineup (Original Array)
const numbers = [1, 10, 2, 25, 5];

// The Attempted Sort
numbers.sort();

console.log(numbers);

// Output:
// [1, 10, 2, 25, 5]

📋 The Expected vs. Actual Output

Intended Behavior (Math) Actual Reality (JavaScript) Status
[1, 2, 5, 10, 25] [1, 10, 2, 25, 5] ❌ Maliciously Broken

Look closely at that result.

10 comes before 2?

25 comes before 5?

Did mathematics quietly stop working overnight?

Not exactly.

🕵️‍♂️ The Culprit: UTF-16 String Sorting

When you call .sort() without providing a comparator function, JavaScript does not sort numbers mathematically.

Instead, it secretly performs a type coercion operation and converts every element into a string first.

Our innocent integers suddenly became:

  • 1 → "1"
  • 10 → "10"
  • 2 → "2"
  • 25 → "25"
  • 5 → "5"

Once converted to strings, JavaScript compares them character-by-character using UTF-16 code unit values.

Essentially, the engine starts sorting your numbers like dictionary words.

📖 Think of It Like a Dictionary

In a dictionary:

  • "Apple" comes before "Banana"
  • because "A" comes before "B"

JavaScript applies that exact same logic here.

For example:

  • "10" starts with "1"
  • "2" starts with "2"

Since the character "1" has a lower UTF-16 value than "2", JavaScript places "10" first.

The engine isn’t malfunctioning.

It’s simply following text-based sorting rules.

🛠️ The Fix: Deploying a Comparator Function

To force JavaScript to sort numerically, you must explicitly tell the engine how two values should be compared.

Here’s the proper fix:

const numbers = [1, 10, 2, 25, 5];

// Numeric Sort Fix
numbers.sort((a, b) => a - b);

console.log(numbers);

// Output:
// ✅ [1, 2, 5, 10, 25]

One tiny comparator function.

Massive difference.

⚙️ How the Comparator Actually Works

This line:

(a, b) => a - b

subtracts the second value from the first.

JavaScript then interprets the result.

Negative Result

If the result is negative:

2 - 5 = -3

JavaScript understands that 2 should come before 5.

Positive Result

If the result is positive:

10 - 2 = 8

JavaScript knows 2 should move ahead of 10.

Zero

If the result is 0, both values are considered equal, and their order remains unchanged.

💡 Pro Tip: Reverse Sorting

Need descending order instead?

Just flip the subtraction:

numbers.sort((a, b) => b - a);

Output:

[25, 10, 5, 2, 1]

🚨 Case Closed

Another JavaScript mystery solved.

The golden rule?

Never trust the default behavior of .sort() when working with numbers.

Always provide a comparator function if you want predictable numeric sorting behavior.

Otherwise, JavaScript will happily treat your integers like alphabetized grocery items.

And that’s how 10 ends up beating 2 in a fight it should never win.

Have you ever been burned by JavaScript’s default .sort() behavior in production?

Drop your horror stories below 👇

Project-as-code for a Directus v9 backend

In practice, turning Directus into the backbone of a production backend takes a fair amount of setup before a project becomes productive. Projen, a project scaffolding and automation framework, can automate that plumbing, so the time the tech teams ‘d otherwise need to spend wiring Dockerfiles and build pipelines together goes into writing product and business logic instead.

@wbce/projen-d9 is the projen template we built to automate that plumbing around directus. By default it targets d9 (directus version 9), but you can adapt it to whichever Directus version you actually run. You can also customize it further to fit whatever your team needs. This post walks through what it generates and why.

d9 is a GPL fork of Directus v9 we maintain on GitHub. It does what Directus 9 did: an open-source (GPL-3 license) CMS that, with the right discipline, can stand in for a full custom backend (API, admin UI, permissions, schema), with business logic added as extensions (hooks, endpoints, operations, custom UI panels).

Bootstrap a new project

npx projen new --from @wbce/projen-d9

This creates a .projenrc.js and synthesizes the project.

What you get

The user-facing surface of the template is .projenrc.js:

import { D9Project } from '@wbce/projen-d9';
import { D9ExtensionType } from '@wbce/projen-d9-extension';

const project = new D9Project({
  name: 'my-backend',
  defaultReleaseBranch: 'main',
});

project.addExtension('audit-log', [D9ExtensionType.HOOK]);

project.synth();

npx projen synthesizes everything else (Dockerfile, docker-compose, extension folder, build pipeline, GitHub workflows). projen calls this “project-as-code”: project files are outputs synthesized from a config you commit, you re-synth whenever the config changes, and the generated files keep tracking the config over time.

Generated tasks

Task Description
first-run Bootstrap a complete local dev environment in one cli command
run Start d9 (docker compose up directus)
build-extensions Install and build all extensions
create-an-admin Create the default admin user

What gets generated

  • docker-compose.yml: d9, Postgres (PostGIS), Redis
  • Dockerfile: Node 22 + pnpm, builds extensions
  • .env.local: sample for local environment overrides
  • extensions as a pnpm workspace
  • GitHub PR and issue templates via
    @wbce/projen-shared (set
    githubConfig: false to disable)

A standardized compose stack

With a single CLI command, you can spin up a fully working local Directus environment (database, cache, and API) ready for development. It mirrors a production setup, including caching and geospatial capabilities.

The compose file is built with projen’s DockerCompose construct:

  • database: postgis/postgis:13-master in order to enable geospatial functionality
  • cache: redis:6
  • directus: built from the local Dockerfile. EXTENSIONS_AUTO_RELOAD=true is set, so re-running npx projen build-extensions is enough to pick up edits with no container restart.

Bind mounts:

  • ./uploads -> /app/uploads
  • ./extensions -> /app/extensions (the extension tree that build-extensions writes into)
  • ./.env.local -> /app/.env.local (so secrets stay out of the image and git versioning)

Extensions as a pnpm workspace

addExtension(name, types, options?)adds an ExtensionFolder to the parent project. Each addExtension creates a D9ExtensionProject with three quirks:

  1. Its package.json gets the directus:extension field correctly configured.
  2. Its build task is rewritten to compile and then deposit the output in the right subfolder of extensions/
  3. If any of the types are UI extensions (INTERFACE, DISPLAY, LAYOUT, MODULE, PANEL), vue is added as a devDep automatically.

Because every extension is its own package in a real pnpm workspace, we can share packages between extensions :

// A shared library, no extension types
project.addExtension('shared', []);

// Other extensions depend on it via workspace:
const myHook = project.addExtension('audit-log', [D9ExtensionType.HOOK]);
myHook.addDeps('shared@workspace:');

And pnpm catalogs in pnpm-workspace.yaml let you pin shared dependency versions in one place and consume them as catalog: from each extension’s package.json.

Deploying

The generated Dockerfile is meant for deployment. Build and push it to your
registry, then run it against your target database and cache.
Simple and efficient, the Docker setup ensures extensions are built inside the image during the build step.

FROM node:22-bookworm-slim
RUN npm install -g [email protected]
RUN npm install -g [email protected]
COPY package*.json /app/
WORKDIR /app
RUN NODE_ENV=production npm install
COPY . .
RUN npx projen build-extensions
CMD ["npx", "directus", "start"]

Integrate into an existing project

Follow this section of the documentation

Links

Issues and PRs welcome.

A 30-day playbook for building internal AI teams in SMBs

We’ve open-sourced a 30-day playbook for SMBs (10-100 people) looking to build their own internal AI capability. It’s a free document with reference Python notebooks, available at [github link].

After 12 engagements with SMBs in Asia and the US, we kept seeing the same three failure modes: teams treating AI as a magic box, starting with tools instead of problems, and attempting to boil the ocean. This playbook is our attempt to address those patterns.

The document structures a month-long rollout across four weeks: problem scoping and data inventory (week 1), model selection and prototyping (week 2), integration and workflow design (week 3), and team scaling and governance (week 4). It includes four reference notebooks covering data preparation, prompt engineering, model evaluation, and deployment patterns. Three case study writeups show how teams applied this framework to real problems in customer support and operations.

What’s not included: no SaaS platforms, no tool recommendations, no promises of “AI transformation.” We’re not selling anything—just sharing what’s worked in the field.

The playbook comes from 12 engagements across different industries. The sample size is small, and we’re sharing it to get feedback from others doing this work. We expect it will need refinement as more teams experiment with this approach.

We’re particularly interested in hearing about patterns we’ve missed, assumptions that don’t hold in other contexts, and alternative approaches that have worked for others. The GitHub repo includes an issues section for structured feedback.

This piece is from our notes on helping SMBs (10-100 people) build their first in-house AI teams. If your team is exploring this — quick feedback and questions welcome in the comments.

I finally shipped the plugin I’d been putting off for years. Then I started questioning why I built it.

A few days ago I checked off something that had been sitting in my todo list for a long time.

It’s a small develop plugin called unplugin-drop-committed. It removes method calls like console.log from your code during development — but only the ones that have been committed to Git. Your uncommitted lines are safe.

The idea came from a real frustration: a colleague had left console.log calls inside polling loops. The browser console was unusable. I didn’t want to ban console.log globally — I just wanted the committed noise gone.

Simple enough concept. I had the idea. I never built it.

Why it sat there so long

I care deeply about DX tooling. Probably more than is professionally sensible. I’ve filtered job opportunities based on whether I’d be working on developer experience. I think it’s underrated craft — the invisible layer that makes other developers’ days slightly less painful.

But DX is almost always low priority at work. There’s always a feature to ship, a bug to fix, a deadline to hit. The DX improvements I want to make get pushed to “later”. Later becomes the weekend. The weekend fills up. The todo list grows.

unplugin-drop-committed sat there for a long time. Not because it was hard. Because I never had the uninterrupted time to start.

How AI changed that

I used Claude to help me build it. I want to be clear about what that means: the ideas, the design, the four removal modes — those were mine. What AI gave me was momentum. It handled the boilerplate, suggested approaches I hadn’t considered, and kept me from getting stuck on implementation details that would have cost me a whole evening.

It took a fraction of the time I expected. I finally crossed something off the list.

For someone like me — someone with a backlog of DX ideas and a shortage of free hours — AI is genuinely liberating. It’s not replacing my thinking. It’s removing the friction that was keeping my thinking from becoming real things.

The question that followed

The satisfaction lasted maybe ten minutes.

Then I started thinking about who would actually use this.

AI-assisted development is changing how code gets written. AI agents don’t forget to delete their console.log calls the way humans do — or if they produce noise, it’s a different kind. The problem this plugin solves is a human problem, shaped by human habits.

I asked Claude how I should position the plugin. It suggested: “unplugin-drop-committed helps AI coding agents stay focused — keeping debug output isolated to the module being worked on.”

That’s… not wrong. It’s actually an interesting reframe. But it also felt like I was watching my motivation quietly shift from “making developers’ lives better” to “making AI agents’ workflows cleaner.”

DX. AX. Developer Experience. Agent Experience.

I’m not sure when the transition happened, or if it already has.

What I actually believe

I don’t think DX is dead. I think the audience is changing, and the best DX tools will be the ones that serve both humans and the agents working alongside them.

I also think there’s something worth preserving about caring whether the developer console is readable. Whether the hot reload feels instant. Whether configuration is intuitive. These things still matter, even if fewer people are writing the code by hand.

And I’m going to keep building these tools. AI gave me back the time to do it. I’d rather use that time on the things I actually care about than spend it wondering whether caring still counts.

If you want to try it:

https://github.com/KID-joker/unplugin-drop-committed

Four modes: strict (committed lines), file (committed files), user (other authors), time (older than N days/months). Vite, Webpack, Rollup, Rspack. Vue and Svelte script blocks included.

I’d genuinely like to know: do you think DX tooling still matters the same way it did two years ago?

Google, Gemini için “CapCut” ile iş birliği gerçekleştirdi

Google, Gemini için CapCut ile iş birliği gerçekleştirdi

Google, Gemini için CapCut ile iş birliği gerçekleştirdiGoogle, I/O 2026’da çok büyük duyurular yaptığı Gemini için CapCut ile iş birliği gerçekleştirdiğini duyurdu. Bu konuda yapılan resmi açıklamada, “Kullanıcılar yakın zaman içerisinde CapCut’ın gelişmiş düzenleme yeteneklerini kullanarak doğrudan Gemini uygulaması içerisinde fotoğraf ve video düzenlemeleri yapabilecek. Bu iş birliği sayesinde yaratıcı iş akışları daha bağlantılı ve kusursuz hale gelecek. Araçlar arası akıllı entegrasyonla içerik üretim süreçlerinin gelecekte daha sezgisel, diyalog tabanlı ve kesintisiz bir yapıya kavuşacağına inanıyoruz. Bu henüz sadece bir başlangıç.” ifadelerine yer verildi.

Google, Gemini için “CapCut” ile iş birliği gerçekleştirdi yazısı ilk olarak Teknoloji haberleri – LOG üzerinde yayınlandı.

When the API literally burned your database after a typo

The Problem We Were Actually Solving

We needed a staging environment that could tolerate the stupidity of humans. Not a toy cluster that looked like production but couldnt survive a mis-typed curl flag. The real requirement was: if an engineer turns staging into a dumpster fire at 3 am, nothing outside staging should notice. We also needed to ship a new subscription checkout flow for creators in countries where PayPal blocks transactions. The flow had to store settlement schedules, retry failed charges, and emit events to Kafka so analytics could bill creators in USD without touching the blocked jurisdiction. The first cut used DynamoDB with on-demand capacity and TTLs, but the finance team vetoed it because the eventual-consistency model could under-charge a creator in Kazakhstan by 0.03 USD and we wouldnt know for 12 hours.

What We Tried First (And Why It Failed)

We started with a Terraform module for RDS Postgres 14, parameter group set to db.t3.medium, publicly accessible = true, and storage_encrypted = false. It passed the linting stage because the linter only checked for AWS tags. We deployed staging with terraform apply -auto-approve -var environment=staging. Two weeks later an intern ran a chaos experiment that killed the master node; Prometheus screamed about 503s on /health but the auto-scaling policy had cooldown = 300 seconds and the replacement node took 7 minutes to come up because the init script downloaded 1.2 GB of fonts for a demo dashboard. The payment service still didnt retry DNS, so the first 480 requests failed.

We also tried a separate staging Kafka cluster (msk.t3.small, three brokers) to test exactly-once semantics. The topic auto-created with retention.ms=604800000, which meant a single misconfigured producer could retain a week of test messages and run the cluster out of disk in six hours. Kafka Manager showed the disk usage in red, but the on-call rotation had no threshold rule for broker disk < 30 GB. The staging alert router pointed to a Slack webhook that posted to #staging-alerts; the channel had 477 muted threads and the message was scrolled away before anyone noticed.

The Architecture Decision

We rebuilt staging to be disposable and observable.

First, we replaced the Terraform RDS module with an ephemeral Postgres in Kubernetes using the zalando/postgres-operator. The operator runs a primary pod with a sidecar pgbouncer, a standby set, and a volume snapshot every hour. The snapshot lands in an S3 bucket with SSE-KMS, so even if the cluster melts, a simple kubectl apply -f restore.yaml brings it back in five minutes. We turned publicly accessible off and added an AWS PrivateLink endpoint so the Kubernetes cluster could reach the DB without an internet gateway. The terraform apply now uses –target=module.vpc –target=module.eks first to prevent route table race conditions.

Second, we moved the checkout retry logic from HTTP to a durable queue. Instead of the legacy PHP helper that multiplied null by a million, we wrote a Go service that consumes from an SQS FIFO queue named checkout-payments.fifo with message group ID set to the creators UUID. The queue has visibility timeout 300 seconds and maximum receive count 3. The service writes to Postgres in a transaction, then publishes a Kafka event only after commit. We disabled TTL on the queue because we never want a subscription schedule to evaporate during an outage. The service exports a Prometheus metric checkout_service_retry_count{queue=fifo} that fires an alert if the count > 5 per minute.

Third, we built a nightly chaos pipeline that runs in staging: it randomly kills the Postgres primary, injects 500 ms latency on the PrivateLink, and rewrites a checkout row to status=failed_then_retry=true. The pipeline is scheduled by GitHub Actions at 02:00 UTC and posts results to a dedicated Slack channel. If the primary doesnt recover within five minutes, the pipeline pages the on-call rotation. The chaos job uses terraform destroy -target=module.staging_db followed by terraform apply to ensure

Treated the payment platform as infrastructure. Found the single point of failure. This is the replacement I put in place: https://payhip.com/ref/dev4