Back to BlogFull-Stack

Your Internal Service API Is Not a Public API

After month 18, your internal service contracts become your public API contracts — and your architecture freezes. Here is the boundary you should have drawn from the start, and the real tradeoffs of putting it in place.

API DesignMicroservicesArchitectureAPI GatewaySystem Design
Your Internal Service API Is Not a Public API

The Mistake Everyone Makes Around Month 18

You split your monolith into services. Then — because it was Friday and the sprint was ending — you pointed your mobile app directly at user-service:3001. Temporarily.

That temporary decision lasted 18 months.

Now UserService does 11 different things, the response schema has 47 fields, two of those fields exist only because a client built logic on top of them, and you can't remove them without breaking app version 3.1.1 that 35% of your users are still running.

You've hit the API surface trap: your internal service contracts became your public API contracts, and now your architecture is frozen.

How It Calcifies

The failure is gradual. The service starts clean. Then a client starts reading the internal _sourceSystem field to render an icon. A mobile app constructs a display name by concatenating three fields that were never meant to be user-facing. Another service starts calling you in a tight loop because there is no other way to get the data it needs.

You can't break these callers. So every refactor becomes a negotiation. You add a shim field, mark one as deprecated (knowing you'll never actually remove it), and the schema becomes archaeology.

The real damage: your *internal* architecture is now locked to your *external* contracts. You want to split UserService into ProfileService and AuthService. You can't — not without breaking every client. So you don't. The service grows.

This compounds in a specific way that DDIA calls interface calcification: the longer the interface has been public, the more undocumented behaviors clients depend on, and the harder any change becomes — even changes the interface was never designed to prevent.

Draw the Line

The fix is structural: separate the public API surface (stable, versioned, your promise to the world) from the internal service mesh (free to change, an implementation detail).

The gateway owns the public schema. Internal services own their own schemas. When you split UserService, the gateway adapts — clients never notice.

This doesn't have to be a vendor product. It could be a simple aggregation service, a BFF layer, or a thin GraphQL resolver. The architecture is what matters: one stable contract layer sitting on top of unstable internals.

What the Boundary Actually Buys You

**Internal freedom.** Services can rename fields, change data types, split or merge — as long as the gateway adapter keeps the external contract intact. Engineers can refactor without a client-coordination meeting.

**Explicit versioning surface.** When you do need to break the public API, the change is deliberate and in one place. You run /v1 and /v2 at the gateway, not across 12 services each maintaining their own version story.

**Auth and rate limiting in one place.** Right now you probably have auth middleware duplicated across every service. With a clear external boundary, auth lives at the gateway. Services trust calls that arrive from the gateway — one fewer attack surface per service, and a single place to audit.

**Schema as a first-class artifact.** The public schema can be linted, versioned in git, and used to generate SDK clients. Internal service schemas don't need this level of rigor — they can evolve fast without ceremony.

The Refactor That Was Previously Impossible

Here is what the service split looks like when you have the boundary in place:

The client asked for a user. It got a user. It has no idea you just called two services. If next month you add PreferencesService and fold it into the same response, the client still sees one call, one schema, one contract.

Compare that to the direct-call model: the client would need to know about ProfileService and AuthService separately, handle two different auth flows, two different error shapes, and aggregate the result itself — in JavaScript, in a mobile app, with no transaction boundary. That aggregation logic calcifies in the client just as badly as it does in a monolith.

Tradeoffs Worth Naming

**An extra hop.** Gateway to service adds latency. On p50 it is usually noise; on p99 with a slow internal service it compounds. Use async fan-out for non-blocking upstream calls and set per-upstream timeouts so one slow service doesn't drag the entire response.

**The fat orchestrator trap.** If your gateway starts encoding business logic — "if the user is on the premium plan, call this extra endpoint" — you've moved the spaghetti, not removed it. The gateway translates and aggregates. Business logic stays in domain services. The second you find yourself writing if/else in the gateway based on response data, that branch belongs in a service.

**One more service to operate.** The gateway is now in the critical path of every external request. It needs to be stateless so you can scale it horizontally, and its failure mode should degrade gracefully — circuit breakers, cached fallback responses — not silently drop requests. A gateway that goes down takes everything with it.

Where to Draw the Line

The heuristic: if a field in your public response maps 1:1 to a database column name or an internal implementation detail, it is a leak, not a design.

Public API fields should be defined by what clients need, not by what your database happens to have. When those two things look identical, it usually means the boundary work hasn't been done yet — not that you got lucky with your schema.

You don't need to rewrite everything at once. Add the gateway in front of one high-traffic endpoint, migrate clients to call it, and freeze the internal service's public exposure. The boundary doesn't have to exist everywhere — it just has to exist somewhere before the calcification sets in for good.