What module-level isolation is and who it is for

Module-level isolation means keeping one codebase and one build artifact while treating a single module as its own reliability unit. That module gets its own service level indicators (SLIs), its own error budget and its own failure domain. You still ship the monolith as one deployable, but you can measure the module separately, hold it to its own targets and, when needed, run it on capacity the rest of the application does not share.

The problem it addresses is the noisy neighbor. A monolith runs every feature in one shared runtime, with one connection pool, one thread or worker pool, one memory space and one database. When a report generator saturates the pool or a webhook handler hits a slow downstream, checkout and login slow down even though their code has not changed. A single global availability number makes this worse. It records that the application degraded, but it cannot show which module consumed the budget, so every team pays for one module's failures and nobody can tell which team should act.

The approach suits backend and platform teams that run a shared runtime, can point to one module that is clearly causing the trouble, and have an owner for each module who can be held to a budget. Without owners, a per-module budget turns into a dashboard that nobody acts on.

Extraction into a separate service is the alternative. It gives the strongest isolation, but it adds a network boundary, a separate deploy pipeline and data ownership questions you have to resolve. Isolating the module inside the monolith gets much of the reliability benefit sooner, and it shows you whether extraction is worth doing.

Attributing errors and latency to a module

Before any isolation work, you need numbers that say which module is failing. Most monoliths already emit request metrics and traces. What they lack is a label that ties each request or job to the module that owns it.

Tag at the entry point

Assign the module at the outermost layer, where you know the most about intent: the router for HTTP, and the dispatcher for background jobs. A route table or job-class mapping like /billing/* → billing is explicit and reviewable. Middleware reads that mapping, sets a module attribute on the root span, and adds the same label to the request-duration histogram and error counter. A job wrapper does the same before calling the handler: it looks up the job's owning module, starts a span carrying that attribute, and records outcome and duration with the label attached. Your per-module SLIs then come from queries you already run, filtered or grouped by module.

Keep the label set small and fixed. Free-form values cause cardinality problems in your metrics backend.

Where attribution goes wrong

  • Shared middleware. Authentication or rate-limiting failures that happen before routing get no module label. Some get misattributed to the wrong module. Give them their own platform bucket.
  • Cross-module calls. If orders calls into billing in-process and billing throws, the request is charged to orders. Record the callee as a child span with its own module attribute so you can see who actually failed.
  • Triggered background work. A job enqueued by orders but owned by reports should carry the owner's label. Store the originating module as a separate attribute rather than inheriting it. Otherwise one module's backlog gets charged to another module's budget.
import time
from contextlib import contextmanager
from opentelemetry import trace
from prometheus_client import Counter, Histogram

tracer = trace.get_tracer("app.modules")

# Explicit, reviewable ownership map: job type -> owning module.
JOB_OWNERS = {"generate_invoice_pdf": "billing", "nightly_sales_report": "reports"}
KNOWN_MODULES = {"billing", "orders", "reports", "platform"}

# Small, fixed label set keeps metric cardinality bounded.
JOB_DURATION = Histogram("job_duration_seconds", "Job run time", ["module", "job"])
JOB_ERRORS = Counter("job_errors_total", "Failed job runs", ["module", "job"])

def run_job(job_name, handler, payload, enqueued_by="unknown"):
    # Charge the job to the module that owns it, not the one that enqueued it.
    # Unmapped jobs land in 'platform' instead of silently borrowing a label.
    owner = JOB_OWNERS.get(job_name, "platform")
    origin = enqueued_by if enqueued_by in KNOWN_MODULES else "unknown"
    # The originating module goes on the span only, for debugging, not on metrics.
    attrs = {"module": owner, "module.origin": origin, "job.name": job_name}
    start = time.perf_counter()
    # start_as_current_span records the exception and sets ERROR status on raise.
    with tracer.start_as_current_span(f"job {job_name}", attributes=attrs):
        try:
            return handler(payload)
        except Exception:
            JOB_ERRORS.labels(module=owner, job=job_name).inc()
            raise
        finally:
            JOB_DURATION.labels(module=owner, job=job_name).observe(time.perf_counter() - start)

@contextmanager
def module_call(callee, operation):
    # Wrap in-process calls into another module in a child span tagged with the
    # callee, so a billing failure inside an orders request shows up as billing's.
    with tracer.start_as_current_span(f"{callee}.{operation}", attributes={"module": callee}):
        yield

A job wrapper that labels metrics and spans with the job's owning module and records the enqueuing module only as a span attribute, plus a helper that tags in-process cross-module calls with the callee's module on a child span.

Setting per-module SLOs and an error budget policy

Pick each module's target from what its callers actually need. Checkout failing blocks revenue. A nightly export failing means a retry. If both share one 99.9% target, the export becomes a source of false alarms, or the target gets loosened until checkout is under-protected. Start from current measured performance per module and tighten only where users would notice.

Illustrative estimate (assumed numbers, not a benchmark): allowed failures = (1 − SLO) × eligible requests in the window. If reports serves an assumed 400,000 requests in 30 days at a 99.5% target, it may fail 0.005 × 400,000 = 2,000 requests. orders at an assumed 3,000,000 requests and 99.9% may fail 3,000.

The policy is what gives the budget teeth. When reports exhausts its budget, risky changes to reports stop: feature flags stay off, migrations wait, and only reliability fixes ship. Deploys of the rest of the monolith continue. This only works if each module has a named owning team that has agreed in advance to the freeze terms and holds the authority to enforce them. Without that, an exhausted budget is just a red panel on a dashboard that nobody acts on.

Aspect Single global SLO Per-module SLOs
Alerting One noisy module pages everyone Pages go to the owning team
Freeze scope Whole codebase or nothing Only the module over budget
Ownership requirement One team accountable for everything Named owner and agreed policy per module

Diagnosing the noisy neighbor: contention versus the module's own failures

Before isolating anything, find out whether the module is the victim, the aggressor, or simply broken. Isolation fixes contention. It does nothing for a module that fails on its own, and it can hide that failure by making the rest of the system look healthy.

The test is correlation. Plot each module's error rate and latency next to process-wide saturation signals. If several unrelated modules degrade at the same moments, and those moments line up with a saturated resource, you have contention. If one module's errors rise while saturation stays flat and its neighbours stay healthy, the cause is that module's own code, data, or dependency. Start with a bug fix, not a separate deployment.

Symptom Shared resource it points to Metric to check
Latency spikes across modules, low error rate CPU Process CPU utilisation, run-queue length, throttling
Periodic stalls in every module at once Memory / GC GC pause duration and frequency, heap after collection
Timeouts acquiring connections DB connection pool Pool wait time, active vs. max connections
Requests queue before any handler runs Thread or worker slots Busy workers, queue depth, time-in-queue
Slow writes on specific tables Row or table locks Lock wait time, blocked sessions by query origin
One module's 5xx rate climbs, others flat None (module's own failure) Per-module error codes, exception types, downstream dependency errors

Attribute the aggressor as well as the victims. Pool wait time alone tells you a pool is exhausted. Tagging connection checkouts or queries by module tells you which module is holding the connections.

How to isolate a module in a monolith: separate worker deployments and routing

Once you know which module is the aggressor, give it its own fleet. Run the same build artifact as separate deployments, and select each pool's role at startup with an entrypoint argument, an environment variable such as ENABLED_MODULES=reports, or a queue subscription list. Because each pool is its own deployment, the noisy module gets its own replica count, CPU and memory limits, autoscaling signal and restart policy. When the reports pool runs out of memory and restarts, checkout traffic is still served by pods that never loaded the report code path.

In the target topology, the ingress fans out to a general pool and a reports pool, a job queue feeds a dedicated worker pool, and every pool connects to the same database.

HTTP routing. Add a path-based rule at the load balancer or ingress, for example sending /reports/* to the reports pool and everything else to the general pool. Keep the general pool able to serve those paths during rollout. Shift traffic by weight, or keep a catch-all rule, so that reverting is a routing change and not a redeploy.

Background jobs. Publish the module's jobs to their own queue or topic, and have only the dedicated workers subscribe to it. During migration, let the general workers drain the old shared queue until it is empty.

Limits. Every pool runs the same build, so a bad release still reaches all of them at once. This approach isolates runtime behavior, not deploys. Each extra pool also adds configs, dashboards and alerts that can drift out of sync. Generate them from one template rather than maintaining copies by hand.

# Same image as the general pool; only the module flag and limits differ.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-reports
spec:
  replicas: 2
  selector:
    matchLabels: {app: shop, pool: reports}
  template:
    metadata:
      labels: {app: shop, pool: reports}
    spec:
      containers:
        - name: app
          image: registry.example.com/shop/app:1.42.0  # same tag as app-general
          env:
            - name: ENABLED_MODULES   # read at startup to pick this pool's role
              value: "reports"
          resources:
            limits: {cpu: "1", memory: 2Gi}  # an OOM here restarts only this pool
---
# /reports goes to the reports pool. The "/" catch-all keeps the general pool
# as the fallback: deleting the first path reverts routing with no redeploy.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
spec:
  rules:
    - http:
        paths:
          - path: /reports
            pathType: Prefix
            backend:
              service: {name: app-reports, port: {number: 80}}
          - path: /
            pathType: Prefix
            backend:
              service: {name: app-general, port: {number: 80}}

This Kubernetes example runs the shared image as a separate reports Deployment, selected by an ENABLED_MODULES flag and given its own limits, and uses a prefix rule in the Ingress to send /reports there, while a catch-all route keeps the general pool as the fallback.

Runtime guards inside the process and at the shared database

When a module has to share a process with everything else, put a bulkhead around it. Give it a dedicated thread pool or a semaphore that caps how many of its requests run at once. When the cap is reached, reject the request immediately instead of queueing it. Fast rejection counts against the module's error budget. Unbounded waiting holds threads and sockets that other modules need. Each dependency the module calls should get its own timeout and circuit breaker, so a slow downstream service fails that module's calls quickly instead of tying up shared workers.

Load shedding keeps failures contained. Use per-module rate limits and a maximum queue depth, and return degraded responses such as cached data, partial results, or a clear "try later" when the module is under pressure. These make overload show up as the module's own errors rather than as latency across the whole application.

These guards leak. A semaphore does not stop the module from exhausting the heap, causing long GC pauses, or using all the CPU. Static limits that are set too low can also cause an outage for the module even when the rest of the system is healthy.

The shared database is usually where modules are actually coupled. Give each module its own connection pool and size it so the pools together stay under the server's connection limit. Set statement_timeout per session and set application_name to the module name. That lets you attribute load in pg_stat_activity, and query comments can carry the same tag. Send heavy reads to a read replica so a report scan does not compete with writes on the primary.

import threading
from psycopg2.pool import ThreadedConnectionPool

# Bulkhead: at most 5 concurrent billing requests in this process.
# Matches the pool size so a request that gets a slot also gets a connection.
BILLING_SLOTS = threading.BoundedSemaphore(5)

# Per-module pool: billing may hold at most 5 of the server's connections.
# Every session is tagged for pg_stat_activity and has a statement timeout.
billing_pool = ThreadedConnectionPool(
    minconn=1,
    maxconn=5,
    host="db.internal",
    dbname="app",
    user="billing_svc",
    password="change-me",
    application_name="billing",
    options="-c statement_timeout=2000",  # ms; the server cancels slower statements
)

class ModuleOverloaded(Exception):
    pass

def get_invoice(invoice_id):
    # Fail fast instead of queueing. The rejection counts against billing's budget.
    if not BILLING_SLOTS.acquire(blocking=False):
        raise ModuleOverloaded("billing at capacity, try later")
    try:
        conn = billing_pool.getconn()
        try:
            with conn.cursor() as cur:
                # The query comment carries the module tag into statement logs.
                cur.execute("/* module=billing */ SELECT id, total FROM invoices WHERE id = %s", (invoice_id,))
                return cur.fetchone()
        finally:
            conn.rollback()  # end the implicit transaction before returning the connection
            billing_pool.putconn(conn)
    finally:
        BILLING_SLOTS.release()  # this caps concurrency only; it does not limit heap, GC or CPU use

A semaphore bulkhead that rejects excess requests immediately, combined with a capped per-module PostgreSQL pool whose sessions set statement_timeout and application_name, so billing's database load can be bounded and attributed.

Verifying isolation, cost impact, and when to extract instead

Prove isolation with a controlled experiment. In staging, or behind a flag on a small production slice, drive the noisy module past its saturation point with synthetic load or injected slow queries. Watch the other modules' latency and error SLIs and the shared database's connection count. Isolation works if those lines stay flat while the noisy module burns its own budget. If they move, find the shared resource the partition missed, such as a connection pool, cache, or lock, before you rely on the setup.

The cost trade-off is mostly engineering time against capacity. You avoid splitting data, versioning contracts, and building a second release pipeline. In exchange, you take on more deployments to monitor and statically reserved capacity that often sits idle.

Illustrative estimate (assumed numbers, not a benchmark): idle capacity = dedicated replicas × (1 − average utilization). With an assumed 4 replicas sized for peak and an assumed 25% average utilization, 4 × 0.75 = 3 replicas' worth sits idle.

Move past this approach when the module needs a different scaling curve or release cadence, or conflicting runtimes or dependencies. Also extract when its data ownership separates cleanly. If imports keep crossing the boundary, isolation is only postponing a split you will eventually need.

Option Blast radius Release isolation Database coupling Operational overhead Effort
Do nothing Whole app None Full None None
In-process guards Reduced None Full Low Low
Separate worker deployments Module's pool Partial Shared, partitioned Medium Medium
Microservice extraction Service only Full Separate High High

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to isolate module in monolith, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.