skip to content

API vs Implementation Separation

Split a module into a small published API and a hidden implementation so consumers cannot reach past the contract. You will cover interface and impl modules, exported versus internal artifacts, and JPMS exports, with the goal of keeping the public surface as small as possible.

part ofSoftware design & architectureoverview, primer and where to startread it →
on this pageshow

questions

5

In a multi-module project, what does it mean to separate a module into a public 'API' part and a hidden 'implementation' part, and why would a team bother doing that instead of just putting everything in one module?

level: juniorimportance: must knowfreq 70%

answer

  1. api vs impl module split
  2. consumer depends on abstraction only
  3. runtime wiring via DI/ServiceLoader
  4. JDBC driver pattern
  5. blast radius of internal changes

basics

~20 s

Split the module into two: a small public part other code depends on (the API), and a bigger hidden part with the real logic (the implementation). This lets you change the hidden part freely without breaking dependents.

solid answer

~40 s

You publish a thin API module containing only interfaces, DTOs, and exceptions that consumers compile against, and keep the concrete classes in a separate implementation module that consumers never reference directly - something like a DI framework or a ServiceLoader wires the real implementation in at runtime. This bounds the blast radius of internal changes: refactors, dependency swaps, or internal helper classes never leak onto a consumer's compile classpath, so you can change internals without a coordinated multi-team release. It also shrinks what a caller needs to understand and speeds up builds, since a change to implementation-only code doesn't force recompilation of every downstream module that only touched the API.

go deeper

for a junior

Should get the basic idea that hiding implementation details protects callers from being broken by internal changes, and can give at least one plausible reason to bother doing it.

for a middle

Should describe the two-module structure concretely (an api module with interfaces/DTOs, an impl module with concrete classes) and know that consumers depend only on the api artifact.

for a senior

Should discuss the trade-offs explicitly (build overhead versus blast-radius reduction), connect it to the Dependency Inversion Principle, and cite a concrete real example such as JDBC.

for a principal

Should discuss when this becomes premature abstraction at organizational scale, and how the decision to split should track the number of independent consumers and expected module stability.

## The mechanism The mechanism is concrete: you create two artifacts, say `payments-api` and `payments-impl`. | Artifact | What it carries | |---|---| | `payments-api` | interfaces, DTOs, exceptions | | `payments-impl` | the concrete classes implementing those interfaces, using internal helpers, database access, and so on | Downstream consumers declare a build dependency only on `payments-api` - nothing in their `build.gradle` or `pom.xml` ever names `payments-impl`. At runtime, some composition point supplies the concrete implementation: - a **dependency-injection container** wires an interface to its bean, - a `ServiceLoader` performs a lookup based on a file listing the implementation class, or - a manual factory/wiring class in an application's startup code `new()`s the concrete type once and hands it out as the interface. Crucially, the consumer's compiled bytecode never references any class from `payments-impl` directly; it only knows about the interfaces and data types the api module exposes, so the class simply is not on the consumer's compile classpath at all, not merely discouraged from use by convention. ## Why the split exists This split exists because module boundaries are one of the main scaling mechanisms software teams have for reducing coupling as a codebase and organization grow. When a caller depends only on an abstraction rather than a concrete class, you get the **Dependency Inversion Principle** in practice: the direction of source-code dependency points toward the stable interface, not toward volatile implementation detail. That means the team owning `payments-impl` can: - refactor internal helper classes, - replace a database library, - change an HTTP client, - or restructure how a request is processed end to end, and none of that work requires touching, recompiling, or re-releasing any consumer, because nothing in the consumer's build ever referenced those internals in the first place. It also gives you an easy **seam for testing**: consumers, and even the impl module's own test suite, can substitute a fake or in-memory implementation of the same interface without needing the real database-backed implementation running at all, which is a large part of why this pattern pairs so naturally with dependency injection. ## The trade-off The trade-off is real ceremony, not a free lunch. Maintaining two modules instead of one means: - two build files to keep in sync, - an extra layer of indirection when navigating code (you jump from an interface in the api module to a concrete class in the impl module to actually read the logic), - and a genuine, ongoing design discipline requirement: someone has to keep deciding what belongs in the public contract versus what stays hidden, and that decision has to be revisited every time the module grows a new capability. **Versioning** also becomes a real question once the split exists - do the api and impl artifacts version together as a pair, or can impl evolve on its own schedule while api stays frozen. For a module with a single caller on the same team and low expected churn, this is often **premature abstraction**: the split adds structural overhead without a real payoff, and a single small module with ordinary public/private visibility inside it is perfectly fine until there is an actual reason to hide something from an actual second consumer. ## Failure modes 1. **The classic failure mode is a leaky api module.** Someone adds a concrete implementation class, a framework-specific type such as a persistence entity, or an internal-only exception to the api module simply because it was convenient at the time, defeating the entire purpose of the split. In production this shows up as an inability to refactor: the impl team wants to rename or delete an internal helper method, but they cannot tell whether some other team is depending on it through a type that leaked into the api module, so the refactor stalls or gets shelved. 2. **Another common failure is the api module growing without bound**, as methods get added but never pruned, until the 'public contract' is effectively the whole codebase and the separation provides no benefit while still costing the extra module overhead. ## A widely known real-world instance A widely known real-world instance of exactly this pattern is **JDBC**: `java.sql` ships as the API - `Connection`, `Statement`, `ResultSet`, `Driver` - while PostgreSQL, Oracle, MySQL and other vendors each ship a separate implementation jar containing the concrete `Driver` implementation, discovered at runtime through `java.util.ServiceLoader`. An application written against `java.sql` types can switch database vendors by swapping which vendor jar sits on the classpath, without changing a single line of application code, precisely because the application only ever compiled against the api types and never against any vendor-specific implementation class. That is the payoff the split is designed to buy: the ability to change, swap, or evolve what is hidden without ever having to coordinate with, or break, everyone who depends on what is exposed.

  • What's the difference between this module-level split and simply making implementation classes package-private within a single module?
    Package-private visibility is enforced by the compiler at the source level and only works within one compilation unit - anyone who can add a class to that same package still sees everything. A module-level api/impl split is enforced by physically keeping impl classes out of a separately compiled artifact, so a consumer maintained by a different team, pulling in only the api jar, literally cannot reference impl classes regardless of any package-visibility rules inside the impl module. The two mechanisms solve related but distinct problems and are often combined.
  • When would you deliberately choose not to do this split?
    When a module has a single internal caller on the same team, low expected internal churn, and no plan for external consumers, the two-module overhead - extra build files, extra indirection, ongoing discipline about what's public - usually outweighs the encapsulation benefit. It's reasonable to start with one module and introduce the split later once a second real consumer or a real need to hide internals actually appears.

A restaurant's menu is the API and the kitchen is the implementation: customers order from the menu, and the kitchen can change recipes, suppliers, or staff behind the scenes without reprinting the menu, as long as the dish that arrives matches what was promised.

saying these in an interview costs you the question

  • Argues interfaces alone (in one module) are just as good, without acknowledging the compile-classpath leakage difference a separate impl module prevents
  • Cannot explain why compile classpath visibility matters versus only runtime visibility
  • Conflates this with plain private/package-private visibility inside a single module
  • Thinks the api module should contain implementation classes for convenience

context

open as a page

In a Gradle multi-module Kotlin or Java build, what's the difference between declaring a dependency with the api configuration versus the implementation configuration, and how does that difference help enforce API and implementation separation?

level: middleimportance: must knowfreq 65%

basics

~20 s

api means 'this dependency is part of what I expose,' so it shows up on their build too. implementation means 'I use this internally,' so it stays hidden - consumers can't see or accidentally depend on it.

open as a page

How does the Java Platform Module System (JPMS), introduced in Java 9, enforce API versus implementation separation at the language and runtime level, and how is that different from just relying on the public access modifier?

level: seniorimportance: must knowfreq 45%

basics

~20 s

JPMS lets a module list, in a module-info.java file, exactly which packages it exports. A public class in a non-exported package is completely invisible outside the module - the JVM blocks access, not just a naming convention.

open as a page

When designing a module's public API surface, what's the practical process for deciding what goes in the API versus what stays in the implementation, and what goes wrong when a team just exposes everything 'to be safe'?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Only put in the API the smallest set of types and methods other code actually needs. Everything else stays hidden. Exposing extra stuff 'just in case' makes it much harder to change later, because someone eventually relies on it.

open as a page

At an organization with dozens of teams sharing internal library modules, how do you keep the api/implementation split from eroding over time, and what tooling or process actually enforces that a published api module's contract doesn't silently break its consumers?

level: principalimportance: should knowfreq 30%

basics

~20 s

You need automated checks, not just good intentions - tools comparing a new API module version's bytecode against the previous one, flagging anything that would break callers, plus a versioning rule enforcing the intended split.

open as a page