If you're designing a reusable internal module (shared across multiple teams or services), the biggest goal is to make it predictable to consume and safe to evolve. Stability comes less from never changing APIs and more from making changes in a controlled, intentional way.
Here are the practices that tend to work well.
1. Keep the public API intentionally small
Treat every exported function, class, or type as a long-term commitment.
Instead of exposing implementation details:
public/
Client
Config
Errors
internal/
Cache
Parser
Transport
The smaller the public surface, the easier future refactoring becomes.
2. Separate public and internal code
Make it obvious what consumers may rely on.
Example:
module/
api/
internal/
experimental/
or
com.company.library
com.company.library.internal
Anything under internal should be free to change without notice.
3. Follow semantic versioning
A common approach:
| Change | Version |
|---|
| Bug fix | 1.4.2 → 1.4.3 |
| Backward-compatible feature | 1.4.2 → 1.5.0 |
| Breaking change | 1.4.2 → 2.0.0 |
Consumers quickly learn what to expect.
4. Prefer additive evolution
Instead of changing behavior:
Bad:
connect(timeout)
↓
connect(timeout, retries)
Good:
connect(timeout)
connect_with_options(options)
Or make new parameters optional with sensible defaults.
Adding is usually safer than modifying.
5. Deprecate before removing
Typical lifecycle:
- Introduce replacement.
- Mark old API deprecated.
- Document migration.
- Remove only in the next major version.
Example:
@Deprecated
oldMethod()
newMethod()
Consumers appreciate advance notice.
6. Define compatibility guarantees
Document what is considered stable.
Example:
- ✅ Public APIs stable
- ✅ Error types stable
- ✅ Configuration format stable
- ❌ Internal packages may change
- ❌ Log messages not guaranteed
- ❌ Performance characteristics may vary
This sets clear expectations.
7. Avoid leaking implementation details
Don't expose things like:
- database schema
- HTTP client choice
- cache implementation
- threading model
- serialization library
Depend on abstractions instead.
8. Version your data formats
If your module exchanges data:
{
"version": 2,
"data": { ... }
}
or
syntax = "proto3";
Schema evolution is often harder than API evolution.
9. Maintain strong automated compatibility tests
Useful test categories include:
- unit tests
- integration tests
- regression tests
- backward compatibility tests
- serialized format compatibility tests
Some teams keep tests written against older versions to verify that new releases still behave correctly.
10. Write migration guides
For every significant release, document:
Removed:
- oldFoo()
Added:
- newFoo()
Migration:
Before:
oldFoo()
After:
newFoo(options)
This reduces upgrade effort.
11. Be careful with exceptions
Changing exception behavior can break consumers.
Instead of:
v1:
throws IOException
v2:
throws RuntimeException
Prefer:
- keeping existing exceptions where practical
- introducing more specific exceptions as subclasses when appropriate
- documenting new failure modes
12. Design for extension
Prefer extension points over requiring consumers to fork the module.
Examples include:
- interfaces
- callbacks
- plugins
- strategy objects
- dependency injection
This makes customization easier while preserving a stable core.
13. Version dependencies thoughtfully
Avoid forcing consumers into dependency conflicts.
Good practices:
- minimize public transitive dependencies
- avoid exposing third-party types in your public API when possible
- pin internal dependencies while allowing reasonable compatibility ranges where appropriate
14. Treat documentation as part of the API
Consumers rely on documentation just as much as code.
Include:
- version introduced
- deprecation notices
- thread-safety guarantees
- performance expectations
- examples
- compatibility notes
A practical release process
A predictable release workflow might look like this:
- Develop new functionality.
- Add tests and documentation.
- Introduce replacements for any APIs that will be retired.
- Deprecate old APIs rather than removing them immediately.
- Publish a minor release.
- Gather feedback and allow consumers time to migrate.
- Remove deprecated APIs only in the next major release.
This approach balances stability with the ability to improve the module over time.
Guiding principle
Think of your public API as a contract. Internal implementation should be easy to change; the public interface should change rarely, evolve incrementally, and provide clear migration paths when it does. Teams that keep the public surface small, follow semantic versioning consistently, and prioritize additive changes generally find it much easier to maintain reusable modules over the long term.