What counts as an internal API in a monolith

In a monolith, an internal API is anything one module exposes that another module depends on. That includes exported types and functions, service interfaces registered in a dependency container, events the module publishes, and database tables or views that other modules query directly. Everything else is internal: helper classes, private schemas, and implementation details the owning team can change freely.

This boundary has to be written down before any versioning starts. You can't version a surface you haven't defined. If another team's code already reads your orders table, that table is part of your public API, whether or not you intended it to be. Making the surface explicit through package visibility, a declared exports list, or an architecture test turns accidental coupling into a known set of contracts. Only then can you say what a "breaking change" means.

The approach in this guide applies to backend and platform engineers running monoliths where several teams share one codebase. Teams in that setup use internal versioning so that a large refactor can land in stages without a coordinated big-bang change.

The core argument is that internal versioning is a temporary migration mechanism with an expiry date. It is not a permanent parallel-API scheme. Public HTTP APIs keep old versions alive because their callers are outside your control. Inside a monolith, the compiler finds static callers, the monorepo lets you edit them, and a single deploy ships producer and consumers together. You can move every caller yourself, so an old version that never gets removed is a choice, not a constraint.

// modules/orders/index.ts
// The ONLY file other modules may import from. Anything not exported here is
// internal and can change without notice. An architecture test or lint rule
// should reject imports of modules/orders/internal/**.

// --- Public types ---
export type { OrderId, OrderSummary } from './internal/model';

// --- Public service interface (registered in the DI container) ---
export interface OrderService {
  getSummary(id: OrderId): Promise<OrderSummary>;
}

// --- Public events other modules subscribe to ---
export type { OrderPlaced, OrderCancelled } from './internal/events';

// --- Temporary migration shim (versioned surface with an expiry) ---
// v1 returns the old flat shape; v2 is the target. The shim exists only
// until every caller in the repo is moved, then it is deleted.
/**
 * @deprecated Use OrderService.getSummary. Remove by the end of the
 * orders-refactor milestone; owner: orders team.
 */
export { getOrderSummaryV1 } from './internal/legacy-v1';

// --- Shared database contract ---
// Other modules read the `orders_read` view, never the `orders` table.
// The view's columns are part of this module's public API; the table is not.
export const ORDERS_READ_VIEW = 'orders_read' as const;

// NOT exported: repositories, pricing helpers, private schemas.
// Those stay internal to the orders module.

A module entry point that makes the public surface explicit: types, a service interface, events, and a shared read view are exported. One deprecated v1 function is kept only as a migration shim, with an owner and a removal deadline.

How expand-migrate-contract works for module interface changes

Expand-migrate-contract splits one risky interface change into three ordinary ones, each merged and deployed on its own.

Expand

Add the new interface next to the old one. The old entry point usually becomes a thin adapter that forwards to the new implementation, so there is one body of logic, not two drifting copies. Nothing calls the new interface yet, so this change is low risk and easy to review.

Migrate

Move callers in small batches, grouped by module or team. Each batch is a normal pull request against main. Because both interfaces work at every commit, main stays shippable throughout, and nobody maintains a long-lived refactor branch that rots as other work lands. If a batch causes a regression, you revert that batch. The old path still exists, so rollback is a code revert rather than a repair of data or state.

Contract

Once no callers remain, delete the old interface and its adapter. This is the step teams most often skip. The migration feels finished once the new path carries traffic, and removing the old one rarely gets prioritized. Skipping it leaves two supported interfaces, an adapter nobody remembers, and an open door for new code to call the old path.

The cost is calendar time. A change that could be a single large commit now spans weeks of small merges, and someone has to own it through to deletion. That longer timeline buys you a codebase that deploys safely at every step.

// billing/index.ts: the module's public interface during the expand phase.

export interface ChargeRequest {
  customerId: string;
  amountCents: number;
  currency: 'USD' | 'EUR';
  idempotencyKey: string;
}

export interface ChargeResult {
  chargeId: string;
  status: 'succeeded' | 'declined';
}

// EXPAND: the new interface holds the only real implementation.
export async function createCharge(req: ChargeRequest): Promise<ChargeResult> {
  // ...single body of billing logic lives here...
  return { chargeId: `ch_${req.idempotencyKey}`, status: 'succeeded' };
}

/**
 * @deprecated Use createCharge(). Kept only while callers migrate.
 * CONTRACT: delete this adapter once no callers remain.
 */
export async function chargeCustomer(
  customerId: string,
  amountDollars: number,
): Promise<boolean> {
  // Thin adapter: translate the old signature and forward, so the two paths
  // cannot drift apart.
  const result = await createCharge({
    customerId,
    amountCents: Math.round(amountDollars * 100),
    currency: 'USD',
    idempotencyKey: `legacy-${customerId}-${Date.now()}`,
  });
  return result.status === 'succeeded';
}

// MIGRATE: each caller batch is its own PR, for example:
//   - await chargeCustomer(id, 19.99);
//   + await createCharge({ customerId: id, amountCents: 1999, currency: 'USD', idempotencyKey: orderId });

A module mid-migration: the new createCharge holds the single implementation, the old chargeCustomer is a deprecated adapter that forwards to it so callers can move in small reverting-safe PRs, and the adapter is the thing the contract step deletes.

When a side-by-side version is justified, and when to avoid one

Expand-migrate-contract assumes the new interface can absorb every caller without changing what they observe. When that fails, a true parallel version earns its cost. There are three common cases. The first is a behavior change callers must opt into, such as stricter validation or a different rounding rule, where silently switching everyone would break correct code. The second is cross-team ownership, where you cannot edit the callers yourself and need to give their owners a stable target. The third is a long tail of callers that will take months to drain.

Keep the version local to the module. Use a namespaced package (billing.v2) or a suffixed interface (InvoiceServiceV2) rather than a global API version number. A global number couples unrelated modules and forces everyone to bump together. Implement the old version as a thin adapter over the new one, so there is one real code path and the old surface can be deleted cleanly.

Adapters cost more than they look:

Concern Risk with an adapter
Test surface Both versions need contract tests, so the suite roughly doubles until removal
Error handling Mapping new exceptions to old ones can swallow or misclassify failures
Ordering New code may batch or reorder side effects that old callers relied on
Transactions A call that was atomic may now span boundaries the adapter cannot restore

Skip versioning entirely for small modules with a handful of callers owned by one team. Changing the signature and fixing every call site in one reviewed change is faster and leaves nothing behind to delete.

// billing/v2: the one real implementation. Rounds half-even and rejects bad input.
export class InvalidAmountError extends Error {}

export interface InvoiceServiceV2 {
  createInvoice(customerId: string, amountCents: number): Promise<{ id: string; totalCents: number }>;
}

// billing/v1: the old surface, kept only as a thin adapter over V2.
// Callers that have not opted into V2 behavior keep the shape they depend on.
export class LegacyBillingError extends Error {}

export interface InvoiceServiceV1 {
  // V1 accepted fractional dollars and returned only the invoice id.
  createInvoice(customerId: string, amountDollars: number): Promise<string>;
}

export class InvoiceServiceV1Adapter implements InvoiceServiceV1 {
  constructor(private readonly v2: InvoiceServiceV2) {}

  async createInvoice(customerId: string, amountDollars: number): Promise<string> {
    // Translate the old input shape. V1 rounded half-up, so do that here
    // instead of letting V2's rounding leak into callers that did not opt in.
    const amountCents = Math.round(amountDollars * 100);
    try {
      const invoice = await this.v2.createInvoice(customerId, amountCents);
      return invoice.id;
    } catch (err) {
      // Map only the errors V1 callers know how to handle.
      if (err instanceof InvalidAmountError) {
        throw new LegacyBillingError(err.message);
      }
      // Rethrow everything else unchanged so failures are not swallowed.
      throw err;
    }
  }
}

// Delete billing/v1 and this adapter once the last V1 caller is migrated.

A suffixed V2 interface holds the only real implementation, and V1 is a thin adapter that keeps the old input shape and rounding and maps only known errors while rethrowing the rest.

Making deprecation in a monolith enforceable

A deprecation that only lives in a comment or a changelog is a suggestion. To make it hold, stack three mechanisms.

  1. Mark the old interface in the language itself. Use Java's @Deprecated annotation, a JSDoc @deprecated tag in TypeScript, or a DeprecationWarning in Python. Editors and compilers then flag call sites where engineers are already working, which costs nothing to adopt.
  2. Block new usages with a lint or architecture rule. The rule should reject imports of the old module or calls to the old function.
  3. Exempt today's callers through a checked-in baseline. List each existing call site in a baseline file that CI allows to shrink but never grow. This turns "please stop using this" into a ratchet: migration progress is locked in, and regressions fail review instead of being found months later.

Every deprecation also needs a named owner and an exit condition. The exit condition can be a removal date, or a criterion such as "baseline reaches zero." Have CI read both. Once the deadline passes, the check should fail the build or page the owner. Without that check, the date is just text.

The hard part is choosing how strict to be. A rule that breaks the build can block an urgent fix in an unrelated area, and engineers learn to bypass it. A warning gets buried among hundreds of others and ignored. A workable middle ground:

  • Fail on new usages.
  • Warn on existing ones.
  • Escalate only the deadline check, and route it to the owner rather than to whoever happens to touch the file.

Budget for allowlist upkeep. Baselines go stale, merge conflicts pile up in them, and someone has to approve exceptions. Assign that work to the same owner.

# ci/check_deprecations.py: ratchet for the deprecated legacy_billing module.
import datetime, json, pathlib, re, sys

# Checked-in baseline, e.g.:
# {"owner": "payments-platform", "remove_by": "2027-03-31",
#  "callers": ["app/invoices/export.py", "app/reports/monthly.py"]}
baseline = json.loads(pathlib.Path("deprecations/legacy_billing.json").read_text())
IMPORT = re.compile(r"^\s*(from|import)\s+legacy_billing\b", re.MULTILINE)

found = {p.as_posix() for p in pathlib.Path("app").rglob("*.py")
         if IMPORT.search(p.read_text(encoding="utf-8"))}
allowed = set(baseline["callers"])
failed = False

# Existing callers only warn, so unrelated urgent work is never blocked.
for f in sorted(found & allowed):
    print(f"warning: {f} still imports legacy_billing (owner: {baseline['owner']})")

# New callers fail: the baseline may shrink but never grow.
for f in sorted(found - allowed):
    print(f"error: new import of legacy_billing in {f}; use billing.v2 instead")
    failed = True

# Migrated files must leave the baseline, which locks in progress.
for f in sorted(allowed - found):
    print(f"error: {f} no longer imports legacy_billing; remove it from the baseline")
    failed = True

# Once the deadline passes, remaining callers fail the build.
# Route this failure to the owner, not to whoever touched the file.
if datetime.date.today() > datetime.date.fromisoformat(baseline["remove_by"]) and found:
    print(f"error: removal deadline {baseline['remove_by']} passed with "
          f"{len(found)} callers left; escalate to {baseline['owner']}")
    failed = True

sys.exit(1 if failed else 0)

A minimal CI check that warns on existing callers of a deprecated module, fails on new ones, forces the checked-in baseline to shrink as files migrate, and fails once the owner's removal deadline passes.

Measuring migration progress across static and dynamic callers

Start with static counts. On every main-branch build, count references to each deprecated symbol and group them by the team that owns the calling code. Publish the per-team number as a trend line. A shared total hides which teams have stalled. A per-team count gives each owner a specific number to bring down and makes regressions visible within one build.

Static counts have a known blind spot: callers the compiler never sees. These include reflection, dependency injection containers that wire implementations by type or name, string-keyed dispatch tables, and serialized payloads that name a class or handler. Zero static usages means only that no direct references remain. It does not mean the API is unused. To cover the gap, put a counter or a sampled log line on each old code path and record the caller where you can, such as a stack frame, the DI binding, or the dispatch key. This telemetry has a cost. It adds overhead on hot paths, so sample or aggregate rather than logging every call. It also only reports paths that actually run. A quarterly batch job or a rarely used admin action can stay silent for weeks, so set the observation window to match your longest known business cycle before you delete anything.

For mechanical migrations, such as renames, argument reordering, or a changed import path, use a codemod or automated refactoring tool. Save human review for call sites where behavior changes, such as different error handling, nullability, or ordering. Keep codemod diffs small and split them by owning team. A single repository-wide diff of thousands of lines will get approved without anyone reading it, and the few call sites that needed judgment will go through unexamined.

Versioning non-code contracts: schemas, messages and persisted data

Code-level contracts disappear when the last caller is rewritten. Data contracts don't. A row written last year, a message sitting in a dead-letter queue, or a serialized object in a cache still has the old shape after the deploy that "removed" it. The assumption that one deploy moves everything breaks because a monolith shares more than code. It also shares state, and that state was written by earlier versions of itself.

For any data contract, readers must accept both formats before any writer emits the new one. The contract step waits on evidence that the old format no longer exists. A merged pull request is not that evidence. In practice:

  • Expand: readers tolerate the new field or shape. Writers still emit the old one.
  • Migrate: writers switch over. Backfill stored data, or let it age out.
  • Contract: remove the old read path only after you have checked that nothing in the old format remains.
Contract Where old data hides Safe to contract when
Shared table columns Existing rows, replicas, reporting jobs Backfill is verified and nothing reads the old column
Queued messages Retry queues, dead-letter queues, delayed jobs The maximum retention or retry window has passed since the last old-format publish
Cached or serialized objects Caches, session stores, blobs TTL has expired, or keys are versioned so old entries are never read
Feature-flagged paths Flag configs, per-tenant overrides The flag is fully rolled out everywhere and its overrides are deleted

Put an explicit version marker in serialized payloads. Without one, readers have to infer the format from the shape of the data, and that inference gets fragile once there are three formats in circulation.

Alternatives and impact on cost and engineering time

Approach Risk Calendar time Review load Rollback
Big-bang refactor High: every caller changes at once Short if it lands, open-ended if it doesn't One huge diff nobody can review well Revert everything or nothing
Long-lived refactor branch High merge risk; drift from main Long, with rebase churn Deferred to one painful merge Easy before merge, hard after
Permanent parallel versions Low per change No end date Every fix reviewed twice, indefinitely Easy, but you never finish
HTTP-style global version numbers Medium: one version bump covers unrelated modules Tied to release cadence Coarse and hard to scope Whole-version granularity
Enforced, expiring expand-migrate-contract Low per step Bounded by the deadline Many small, mechanical diffs Per caller or per step

The expiring approach saves time in three ways. Dual maintenance ends on a known date. Each migration diff is small enough to review quickly or to generate. Progress is measurable, so nobody has to guess when contraction is safe. It also adds costs: the ceremony of deprecation markers, keeping the allowlist current and running caller telemetry.

Illustrative estimate (assumed numbers, not a benchmark). Dual-maintenance hours = changes per month to the interface × extra hours per change × months both versions live. Assume 6 changes a month, 2 extra hours each, and an upfront tooling cost of 20 hours for the expiring approach.

  • Permanent parallel versions kept for 24 months: 6 × 2 × 24 = 288 hours.
  • Expiring approach with a 3-month window: (6 × 2 × 3) + 20 = 56 hours.

These savings depend on actually finishing. Before the expand step, name a committed owner and set a scope the team can complete. A refactor that stalls halfway leaves every engineer with two patterns to learn and no deadline for dropping either one.

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 internal api versioning 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.