Back-End & Infrastructure - Design & User Experience

Add UX without a rewrite in a legacy backend codebase

Legacy ERP modernization should begin inside the running codebase, not beside it. My position is deliberately narrow: introduce a reversible seam first, then scale the backend behind that seam, because a rewrite hides integration risk until the final mile. A backend developer new to this work should treat the existing system as the source of truth until production evidence says otherwise.

The first seam should be boring, reversible, and close to the write path

The safest entry point is usually a thin application-layer adapter around one business operation, because it lets you route, observe, and compare behavior without pretending you understand the whole ERP. I would not create a greenfield replacement repo on day one, because the first hard problems are usually hidden coupling, transaction boundaries, and data ownership rather than syntax or framework age.

Pick one operation with real load and bounded blast radius: price lookup, customer credit check, purchase order approval, or inventory reservation. The operation should already have callers, logs, and a business owner who can say whether a result is correct. Avoid the most complex financial posting flow at the start, because failure there can turn a learning exercise into a reconciliation incident.

In a Java 8 or Java 11 ERP, the seam might be an interface plus an implementation selected by a feature flag. In a .NET Framework 4.8 application, it might be a facade registered through Autofac 6 or Microsoft.Extensions.DependencyInjection after a small hosting bridge. In a COBOL-adjacent estate, it may be an HTTP adapter around a CICS transaction using OpenAPI 3.1 documentation and JSON Schema 2020-12 validation. The language matters less than the reversibility, because you need to turn the seam off faster than you can debug the replacement.

Use a feature flag system such as Unleash 5.x, LaunchDarkly SDK 8.x, or even a database-backed toggle if governance requires it. A practical value to tune is 1% of eligible traffic for the first live route, because small samples expose serialization, timeout, and connection-pool mistakes without making every downstream team chase noise. Keep the old path callable from the same process, because a same-process rollback removes DNS, deployment, and service discovery from the emergency path.

Legacy ERP Modernization: Identify Dependencies and Scale Backends gets the dependency problem right, but I would not let dependency mapping become a phase gate, because live traffic usually reveals undocumented batch jobs, stored procedures, and report readers that interviews miss.

Dependency maps should come from production signals before architecture diagrams

Start by instrumenting the seam and its old implementation. OpenTelemetry 1.32.0, Prometheus 2.52, Grafana 11, and structured logs through Serilog 3 or Logback 1.5 give you a map that changes when the code changes. Static diagrams decay quickly in legacy environments, because nightly jobs, stored procedures, and vendor plug-ins often bypass the call graph that developers discuss in meetings.

The first metrics should be boring: request count, error count, p95 latency, database query count, connection-pool saturation, retry count, and queue lag. A measured baseline from Prometheus might show p95 latency at 420 ms for the legacy credit check during business hours; that number is useful because it prevents a new path with 900 ms latency from being called “modern” merely because it uses gRPC.

Trace IDs are more valuable than elegant service names at this stage, because the same order ID may cross HTTP, JDBC, IBM MQ, and a stored procedure before the user sees a response. Use W3C Trace Context rather than a custom correlation header, because libraries in Envoy 1.30, NGINX 1.25 with OpenTelemetry modules, ASP.NET Core 8, Spring Boot 3.3, and Node.js 20 already understand the standard.

The following minimal Prometheus setup runs locally and gives you a place to start measuring a seam exposed on port 8080:

cat > /tmp/prometheus.yml <<'EOF'
global:
  scrape_interval: 15s
scrape_configs:
  - job_name: "legacy-erp-adapter"
    static_configs:
      - targets: ["host.docker.internal:8080"]
EOF
docker run --rm -p 9090:9090 \
  -v /tmp/prometheus.yml:/etc/prometheus/prometheus.yml \
  prom/prometheus:v2.52.0

The 15s scrape interval above is a local experiment setting, not a universal recommendation, because high-cardinality labels can make Prometheus expensive if you copy a development config into production. Keep labels stable and low-cardinality: operation name, route version, result class, and dependency name are useful; customer ID, order ID, and SQL text are cardinality traps.

OpenTelemetry Collector uses vendor-documented defaults of 4317 for OTLP/gRPC and 4318 for OTLP/HTTP, and those defaults matter because legacy firewall rules often allow one path while blocking the other. If you cannot change application code yet, a Java service can still be traced with the -javaagent flag and an environment variable such as OTEL_SERVICE_NAME=erp-credit-seam, because agent-based tracing buys visibility before refactoring buys elegance.

Branch by abstraction beats service extraction until the contract stops moving

The first explicit comparison is this: Branch by Abstraction wins when you need to change behavior inside a risky codebase while keeping one deployable unit, and it costs extra indirection, duplicated paths, and discipline around removing old branches. Extract Service wins when the contract is stable and ownership is clear, and it costs network latency, distributed tracing requirements, deployment coordination, and a new failure mode for every call.

For a backend developer entering legacy modernization, Branch by Abstraction should be the default because it lets you learn the real contract from production behavior before freezing it into REST, gRPC, or events. That claim is arguable, but the reason is concrete: an API contract created too early captures assumptions, while an abstraction inside the existing code can be changed with the same release mechanism as the legacy code.

I would treat Legacy System Refactoring and Scaling Backend Architectures as a useful pressure test for later steps, but I would delay the backend split until the abstraction has survived real traffic, because scaling the wrong boundary only makes the wrong dependency faster.

Use contract tools after the seam has a few live examples. Pact 4.x is useful for consumer-driven contracts when callers are known and cooperative. OpenAPI 3.1 with spectral linting works better when the operation will be consumed broadly over HTTP. Protocol Buffers v3 with gRPC works when you control both sides and need strict message evolution, but it costs more ceremony for teams that mainly debug with curl and logs.

A good early rule is to keep old and new paths in the same transaction boundary until you know which data must move. If the current ERP write updates PostgreSQL 16, Oracle 19c, or SQL Server 2019 tables in one transaction, pushing half of that write into Kafka 3.7 on the first iteration is dangerous because you have changed the consistency model before you can explain the old one.

Run the new path in shadow mode when side effects can be suppressed. For example, calculate tax or availability in both implementations, return the legacy result, and compare the new result asynchronously. A trial window I would tune is 14 consecutive business days, because weekly jobs and month-adjacent processing often reveal differences that a one-day test hides. Store comparison results in a table with input hash, legacy output hash, new output hash, version, and timestamp, because raw payload storage can violate retention rules and make investigations harder.

Scaling starts by reducing write ambiguity, not by adding more pods

Most legacy ERP scaling failures are write-model failures, because reads can often be cached, replicated, or indexed while writes carry business invariants. I would not start with Kubernetes HorizontalPodAutoscaler or more IIS workers, because more workers amplify lock contention and connection-pool starvation when the database is the real serialization point.

Before adding compute, classify the operation as read-only, idempotent write, non-idempotent write, or batch mutation. This classification is practical rather than academic, because each class gets a different migration mechanism. Read-only operations can move behind Redis 7.2 or a PostgreSQL read replica. Idempotent writes can use an idempotency key stored with a unique constraint. Non-idempotent writes need a stronger boundary, often an outbox table and a single writer. Batch mutations need scheduling control before they need new APIs.

The outbox pattern is usually more useful than direct event publishing from legacy code, because the database commit and the event record succeed or fail together. Flyway 10 or Liquibase 4.27 can add an outbox table with minimal ceremony, and Debezium 2.6 can stream changes from PostgreSQL logical replication, MySQL binlog, or SQL Server CDC into Kafka. This is not free, because you now operate connectors, offsets, schema history, and replay logic; it is still safer than publishing an event and then failing the ERP transaction.

Be explicit about numbers. A vendor-published PostgreSQL default for max_connections is 100, and that matters because a “small” adapter with a HikariCP 5 pool of 20 connections across six instances can exhaust the database before any business logic improves. A starting timeout I often set for downstream calls is 800 ms, because a shorter timeout can create false failures during batch windows and a longer one can pin request threads long enough to damage the caller. A measured error budget such as 99.9% successful credit checks per calendar month is useful only if retries, duplicate submissions, and validation failures are counted consistently.

Use queues when the business can tolerate delayed completion, not because queues sound modern. RabbitMQ 3.13 with quorum queues is a strong fit for work distribution and operational simplicity. Kafka 3.7 is stronger for ordered event logs, replay, and many consumers. RabbitMQ costs you less conceptual overhead for task processing, while Kafka costs more operational care but gives better historical replay and consumer independence.

Indexes deserve the same respect as services. Adding a covering index in SQL Server or PostgreSQL can remove the need for a service split when the bottleneck is a predictable read query, because the cheapest architecture change is often the one that reduces database work without changing ownership. That claim is unpopular in rewrite-heavy teams, but it holds when the latency budget is dominated by one query plan rather than application CPU.

Tests should pin behavior before they chase architecture purity

Legacy ERP code often contains business rules that were never written down, so characterization tests are more valuable than clean-room unit tests at the beginning. Use approval testing with ApprovalTests for .NET or Java, golden-master snapshots, and database fixtures through Testcontainers 1.19 to capture what the system does now. These tests may look ugly, but they are honest because they protect behavior that customers already rely on.

Do not mock the database for the first seam unless the database is irrelevant to the operation, because stored procedures, triggers, isolation levels, and implicit conversions are part of the behavior. Testcontainers with PostgreSQL 16, SQL Server containers, or LocalStack for AWS dependencies can make integration tests slower, but the cost is acceptable when the alternative is discovering a trigger dependency after deployment.

Use k6 0.49, Apache JMeter 5.6, or Gatling 3.10 for narrow load tests around the seam, not broad synthetic journeys that hide the cause of failure. A load target to tune might be 2x the measured peak requests per second for the chosen operation, because a seam must survive rollback surges and batch overlap before it earns more traffic. Track p95 and p99 separately, because p95 can look healthy while a small group of users hits connection waits, lock waits, or retry storms.

Mutation is the enemy of easy rollback, so keep schema changes backward-compatible. Add nullable columns before writing them, write both old and new fields before reading the new field, and remove old fields only after telemetry shows no readers. This expand-and-contract sequence is slower than a hard cutover, but it is safer because old binaries, batch jobs, and reports may still read the previous shape.

Code cleanup should follow traffic evidence. OpenRewrite 8 recipes can modernize Java APIs, and Roslyn analyzers can enforce .NET rules, but automated refactoring should come after you know which module boundary matters. Otherwise you create beautiful diffs in code that remains coupled to the same database locks, nightly files, and synchronous approvals.

Start with one reversible edge and make it observable by Friday

Choose one operation, wrap it behind an interface or facade, add OpenTelemetry traces, publish four Prometheus metrics, and route a tiny percentage through the new path under a feature flag. Do that before naming a target architecture, because production comparison will teach you where the real boundary is. Your first win is not a new service; it is a rollback-safe seam that proves where modernization can proceed.