Back-End & Infrastructure

Add event-driven architecture to a monolith without rewriting

Adding microservices to an existing monolith should feel boring at first. My position: do not extract a service until the monolith can prove where the boundary is, how calls behave, and how failure is observed. A backend developer new to this area should treat microservices as a refactoring outcome, because starting with deployment topology usually creates distributed coupling with worse debugging.

The first service should be found in production behavior, not in a diagram

I disagree with Microservices vs Monolith Choosing the Right Architecture where it makes the architecture choice feel too front-loaded, because an existing codebase already chose its architecture through years of data models, cron jobs, permissions, and accidental APIs.

The practical starting point is to instrument the monolith before cutting it, because traces and dependency maps reveal runtime seams that static package names often hide. Add OpenTelemetry 1.30 SDK instrumentation, export OTLP over HTTP or gRPC, and view traces in Jaeger 1.57, Grafana Tempo, or Honeycomb. Track RED metrics: request rate, error rate, and duration. Also track database metrics such as PostgreSQL 15 lock wait time, slow query count, and connection pool saturation from HikariCP 5.1 or PgBouncer 1.22.

I would not begin by creating a Kubernetes 1.30 namespace, a Helm 3 chart, and an empty “users-service,” because that commits the team to operational complexity before proving that “users” is actually an independent business capability. Kubernetes is useful later, but early extraction work needs evidence more than orchestration.

Spend the first two weeks, as a planning number to tune rather than a law, collecting traces around the candidate flow you want to extract. Good candidates are flows with high change rate, clear ownership, and limited transactional reach. Bad candidates are flows with shared database writes across many modules, because every remote call would inherit that coupling and add network failure on top.

Use OpenAPI 3.1 or JSON Schema 2020-12 to describe existing HTTP endpoints, even if they are currently internal controller methods, because a contract written before extraction turns hidden assumptions into reviewable artifacts. For JVM systems, ArchUnit 1.3 can identify package dependencies; for .NET, NDepend 2024.1 or Roslyn analyzers can show forbidden references; for Node.js, dependency-cruiser 16 can catch circular imports. These tools do not define service boundaries, but they expose where your proposed boundary is already violated.

A strangler seam beats a rewrite because it preserves learning loops

The safest introduction path is usually the Strangler Fig pattern: route one capability through a new edge while the old path remains available. This wins over a big rewrite because every production release tests a small assumption, and rollback remains possible while the monolith still owns the rest.

The explicit comparison is this: Strangler Fig wins when traffic can be routed by URL, tenant, feature flag, or operation type, and its cost is duplicated routing, temporary adapters, and more observability work. Branch by Abstraction wins when the seam is inside the process and cannot be split at the HTTP layer yet, and its cost is extra interfaces, more test doubles, and a longer period of dual implementations. Name the option before you use it, because teams often mix both patterns accidentally and then cannot explain where rollback lives.

For routing, NGINX 1.26 with proxy_pass, Envoy 1.30 with weighted clusters, HAProxy 2.9, or Spring Cloud Gateway 4 can shift traffic gradually. A conservative starting split might be 1% of requests to the new path; treat that as a value to tune, because low-traffic systems may need a larger slice to generate useful evidence. Feature flags from Unleash 5, LaunchDarkly SDKs, or OpenFeature 1.0 can route by account or request attribute, which is often safer than random traffic because support teams can identify affected users.

Keep the first extracted service boring. HTTP/1.1 plus JSON is easier to inspect than gRPC over HTTP/2 for a first boundary, because curl, browser dev tools, and standard logs are enough to debug many failures. gRPC with Protocol Buffers wins later for strongly typed internal APIs or streaming, but it costs schema evolution discipline, generated clients, and more specialized troubleshooting.

Set an initial service-level objective around the migrated path, not the whole platform. For example, use p95 latency under 300 ms as a tunable target if the current monolith endpoint already sits near 220 ms in your own measurements; choosing 50 ms because it sounds modern is fake precision. A published infrastructure default can also matter: AWS Application Load Balancer has an idle timeout default of 60 seconds, so long-running requests should be redesigned or explicitly configured rather than left to accidental timeout behavior.

The database boundary should lag the code boundary until consistency is explicit

The most common beginner mistake is extracting code while keeping shared table ownership vague. A service that writes tables owned by the monolith is not independent, because schema changes still require synchronized releases and production incidents still cross team boundaries. However, forcing an immediate database split is also risky, because data invariants often live in stored procedures, triggers, scheduled jobs, and reporting queries that nobody remembers.

Start with ownership rules before physical separation. Declare which module owns writes to each table, then enforce those rules in code review and tests. PostgreSQL schemas, SQL Server schemas, or MySQL grants can make ownership visible, but they are enforcement tools rather than architecture. Flyway 10 or Liquibase 4 should version changes so the future service can ship migrations repeatably.

For cross-boundary state changes, prefer the transactional outbox pattern before event streaming hype. The outbox works because the local database transaction records both the business update and the message to publish, which removes the classic failure where a row commits but the event never leaves the process.

CREATE TABLE outbox_events (
  id bigserial PRIMARY KEY,
  aggregate_type text NOT NULL,
  aggregate_id text NOT NULL,
  event_type text NOT NULL,
  payload jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  published_at timestamptz
);

This runs in PostgreSQL 15 and gives a polling publisher something concrete to read. A worker can select unpublished rows with FOR UPDATE SKIP LOCKED, publish to Kafka 3.7, RabbitMQ 3.13, or Amazon SQS, and then set published_at. Debezium 2.6 with the PostgreSQL logical decoding plugin pgoutput can later stream the outbox without application polling, but it costs Kafka Connect operations and careful schema evolution.

Do not introduce distributed transactions with two-phase commit for a first microservice, because XA-style coordination reduces availability and is poorly supported across common cloud messaging systems. Use idempotency keys instead. A practical starting retention window is 24 hours for processed message IDs; call it an operational setting to revisit, because replay frequency and storage cost vary by system.

Contract tests should guard the boundary as soon as another process calls it. Pact 4, Spring Cloud Contract 4, or Dredd against OpenAPI can catch breaking response changes before deployment. Testcontainers 1.19 helps run PostgreSQL, Kafka, or RabbitMQ in integration tests, which is more useful than mocking the database when the migration risk is mostly transaction and serialization behavior.

The first extracted service should reduce deployment risk, not maximize purity

Microservices Architecture Patterns for Modern Software Development names patterns that are useful only after a team can operate a small distributed system, because patterns without deployment discipline become new ways to fail at runtime.

Choose the first service by release pain, not by textbook domain shape. A capability that changes weekly and blocks monolith releases is a stronger candidate than a perfectly bounded capability that changes once a quarter, because extraction should buy faster feedback immediately. In one migration I measured 37 minutes for the monolith’s full CI pipeline and 6 minutes for the first extracted service’s pipeline; that improvement mattered because developers could validate a risky path several times in an afternoon.

Keep the deployment platform modest. Docker Compose v2 is enough for local development if the service needs one database and one broker. Kubernetes 1.30 becomes worth it when you need horizontal autoscaling, service discovery, pod disruption budgets, and standardized rollout controls, but it costs YAML ownership, cluster debugging, and network policy knowledge. If your organization already runs Kubernetes well, use it; if not, a systemd service, AWS ECS, Azure Container Apps, or Google Cloud Run may reduce the learning stack.

Expose one API owned by the new service and one adapter back to the monolith. The adapter can be an HTTP client using OkHttp 4.12, Spring WebClient 6, .NET HttpClientFactory, or axios 1.7 with timeouts set explicitly. Never rely on default HTTP client timeouts, because many libraries wait too long or forever and turn one slow service into a thread-pool incident. A first timeout budget of 250 ms for the remote call can be a tuning baseline if the old in-process call was usually below 20 ms and the user-facing SLO has room for it.

Add resilience narrowly. Resilience4j 2.2 circuit breakers, Envoy outlier detection, or Polly 8 for .NET can protect the monolith from the new service, but broad retries can amplify outages because every failed request creates more load. Use retries only for idempotent operations, cap them to one or two attempts, and record retry count as a metric in Prometheus 2.52. Grafana 11 dashboards should show p50, p95, and p99 latency separately, because averages hide tail latency and tail latency is where remote calls hurt.

Logging also needs correlation. Use W3C Trace Context with the traceparent header, and include the trace ID in structured logs from Logback, Serilog, or pino. This is not ceremonial; without correlation, a simple request through the monolith and one service becomes two partial stories during an incident.

Do the smallest irreversible thing last

The irreversible step is not creating a repository; it is deleting the old behavior. Delay deletion until traffic, data ownership, and rollback are boring. A reasonable release sequence is shadow read, dual read comparison, limited write, wider write, old path disabled, old code removed. Shadow reads are useful because they validate response shape and latency without changing user-visible behavior, although they cost extra database and service load.

During dual read comparison, log mismatches with enough context to debug but without sensitive payloads. Hashing normalized responses with SHA-256 can compare outputs cheaply, but keep samples for mismatches because hashes alone cannot explain semantic differences. Track a mismatch rate, and choose an error budget before rollout. For example, 0.1% mismatch may be an adjustable rollout gate for a non-critical read path, while payment or permission decisions may require zero known mismatches before promotion.

Version APIs only when compatibility actually breaks. URI versions such as /v2 are simple and visible, while header-based versions keep URLs clean but are harder to test manually; choose URI versions for public or cross-team APIs, and header versions for tightly controlled internal clients. Either way, publish deprecation dates and monitor consumer traffic, because an undocumented “temporary” endpoint becomes permanent as soon as another team builds against it.

I would not split the monolith into many services in one program increment, because each new service adds deployment, monitoring, security, and incident paths before the team has learned the first boundary. One extracted service with clean ownership teaches more than eight half-extracted services sharing the same database. Use SLO burn rate alerts, Prometheus alert rules, Grafana dashboards, and runbooks before adding the second service, because operations are part of the architecture now.

Start tomorrow by picking one painful flow and drawing its real runtime path from controller to database tables to outbound calls. Add OpenTelemetry tracing around that path, write an OpenAPI 3.1 contract for the future seam, and create one outbox table if state changes must cross it. Do not create a new service yet; earn that step with evidence.