Library and Framework Design: Membuat Python Code yang Enak Dipakai
Part 032 — Library and Framework Design: Membuat Python Code yang Enak Dipakai
Membahas desain library/framework Python: public API, ergonomics, extension points, versioning, backward compatibility, deprecation, documentation, typing, errors, packaging, dan design principles.
Part 032 — Library and Framework Design: Membuat Python Code yang Enak Dipakai
1. Tujuan Part Ini
Top-tier Python engineer tidak hanya menulis kode yang bekerja. Mereka menulis kode yang enak dipakai oleh engineer lain.
Library/framework design adalah seni membuat API yang:
- jelas;
- stabil;
- mudah ditemukan;
- mudah dites;
- mudah di-debug;
- typed dengan baik;
- punya error yang membantu;
- punya defaults yang aman;
- extensible tanpa terlalu abstrak;
- backward-compatible sejauh mungkin;
- bisa didepresiasi dengan rapi;
- terdokumentasi;
- tidak mengejutkan.
Banyak codebase internal gagal karena API internal buruk:
- function terlalu banyak parameter;
- error generik;
- global state tersembunyi;
- dependency dipakai secara implisit;
- behavior berubah diam-diam;
- public/private boundary kabur;
- docs tidak sesuai;
- type hints tidak ada;
- extension point tidak jelas;
- konfigurasi tersebar;
- test user experience tidak pernah dipikirkan.
Part ini membahas desain Python API dari sudut pemakai.
Target setelah part ini:
- Memahami public API vs internal API.
- Mendesain function/class yang ergonomis.
- Mendesain defaults yang aman.
- Mendesain error hierarchy library.
- Mendesain extension points.
- Memahami backward compatibility.
- Memahami deprecation dengan
warnings. - Memahami semantic versioning secara praktis.
- Menulis documentation dan examples.
- Mendesain typed API.
- Mendesain package exports.
- Menerapkan prinsip ke
case-trackersebagai library/API internal.
2. API Design Starts from the User
Pertanyaan pertama:
Siapa user API ini?
Kemungkinan user:
- engineer tim sendiri;
- tim lain;
- plugin author;
- CLI user;
- API client;
- internal service;
- future you;
- open source user.
API untuk domain internal berbeda dari public PyPI package.
Contoh user story:
As an application developer, I want to create a case and transition its status without knowing how it is stored.
API ideal:
service = CaseService(repository)
case = service.create_case("Late reporting")
service.transition_case(case.id, CaseStatus.SUBMITTED)
Jika user harus tahu JSON path, domain transition table, and storage format, API boundary gagal.
3. Public API vs Internal API
Public API adalah yang kamu janjikan relatif stabil.
Internal API boleh berubah.
Python convention:
def public_function():
...
def _internal_helper():
...
Module-level underscore menandakan internal.
Package export:
# case_tracker/__init__.py
from case_tracker.domain import Case, CaseId, CaseStatus
from case_tracker.service import CaseService
__all__ = ["Case", "CaseId", "CaseStatus", "CaseService"]
Hati-hati: sesuatu yang diekspor dari __init__.py terasa public.
Rule:
Jangan mengekspos terlalu banyak terlalu awal. Public API adalah janji.
4. API Surface Area
Surface area adalah jumlah hal yang user harus pahami.
Too small:
run_everything(config)
User tidak punya kontrol.
Too large:
CaseFactoryBuilderTransitionManagerRepositoryAdapterConfigurableExecutor(...)
User kewalahan.
Good API exposes:
- common path simple;
- advanced path possible;
- internals hidden;
- names domain-specific;
- sensible defaults;
- explicit extension points.
5. Progressive Disclosure
Progressive disclosure berarti API mudah untuk basic use, tetapi tetap mendukung advanced use.
Simple:
tracker = CaseTracker.in_memory()
case = tracker.create_case("Late reporting")
Advanced:
repository = SqlAlchemyCaseRepository(session_factory)
clock = SystemClock()
id_factory = UuidCaseIdFactory()
tracker = CaseTracker(
repository=repository,
clock=clock,
id_factory=id_factory,
)
Basic user tidak perlu tahu semua dependency. Advanced user bisa mengontrol.
6. Good Defaults
Good defaults reduce friction.
Example:
def create_case(
title: str,
*,
priority: CasePriority = CasePriority.MEDIUM,
) -> Case:
...
Bad default:
def create_case(title: str, notes: list[str] = []):
...
Mutable default bug.
Good defaults should be:
- safe;
- common;
- explicit enough;
- not surprising;
- not hiding production risk.
Dangerous default example:
debug=True
in production-facing API.
7. Keyword-Only Parameters
Use keyword-only for clarity.
def transition_case(
case_id: CaseId,
*,
target_status: CaseStatus,
actor_id: ActorId,
reason: str | None = None,
) -> Case:
...
Call:
service.transition_case(
case_id,
target_status=CaseStatus.SUBMITTED,
actor_id=actor_id,
reason="Intake complete",
)
This avoids argument order confusion.
Use positional for obvious primary inputs. Use keyword-only for options/config/policies.
8. Boolean Parameter Smell
Bad:
def list_cases(include_closed: bool) -> list[Case]:
...
Call site unclear:
list_cases(True)
Better:
def list_cases(*, include_closed: bool = False) -> list[Case]:
...
Better for multiple modes:
class CaseListMode(Enum):
ACTIVE_ONLY = "active_only"
INCLUDE_CLOSED = "include_closed"
def list_cases(*, mode: CaseListMode = CaseListMode.ACTIVE_ONLY) -> list[Case]:
...
Boolean flags can be okay, but make them keyword-only.
9. Return Types Matter
Design return values deliberately.
Create case:
def create_case(title: str) -> Case:
...
Delete:
def delete_case(case_id: CaseId) -> None:
...
Batch import:
@dataclass(frozen=True)
class ImportResult:
imported_count: int
skipped_count: int
errors: list[ImportErrorDetail]
def import_cases(rows: Iterable[CaseRow]) -> ImportResult:
...
Avoid ambiguous tuple:
return imported, skipped, errors
Named result object improves readability and evolution.
10. Error Design for Libraries
Library should define meaningful exception hierarchy.
class CaseTrackerError(Exception):
"""Base class for case tracker errors."""
class CaseNotFoundError(CaseTrackerError):
...
class InvalidCaseTransitionError(CaseTrackerError):
...
class CaseStoreError(CaseTrackerError):
...
Benefits:
- users can catch broad or specific errors;
- errors have semantic meaning;
- errors can carry structured attributes;
- API docs can list failure modes.
Error should include useful attributes:
class CaseNotFoundError(CaseTrackerError):
def __init__(self, case_id: CaseId) -> None:
self.case_id = case_id
super().__init__(f"Case not found: {case_id}")
11. Do Not Hide Root Cause
When wrapping errors:
try:
data = json.loads(raw)
except json.JSONDecodeError as error:
raise CaseStoreCorruptedError(path, "Invalid JSON") from error
User can inspect cause if needed.
For public API, document:
- what exception can be raised;
- when;
- what attributes it has;
- whether operation is partially applied.
12. Sync and Async APIs
Do not mix sync/async casually.
Options:
12.1 Sync library
case = client.get_case(case_id)
12.2 Async library
case = await async_client.get_case(case_id)
12.3 Both, separate classes
class CaseClient:
def get_case(...): ...
class AsyncCaseClient:
async def get_case(...): ...
Avoid:
def get_case(...):
return asyncio.run(self.async_get_case(...))
deep inside library. This breaks users already running event loop.
13. Extension Points
Extension point is where user can customize behavior.
Examples:
- repository protocol;
- id factory;
- clock;
- validator;
- event handler;
- logger;
- retry policy;
- serialization adapter.
Protocol:
class CaseRepository(Protocol):
def get(self, case_id: CaseId) -> Case:
...
def save(self, case: Case) -> None:
...
User implementation:
class RedisCaseRepository:
...
Good extension point:
- small;
- documented;
- stable;
- behavior contract clear;
- tested with contract tests.
Bad extension point:
- too many methods;
- unclear lifecycle;
- relies on internal state;
- changes frequently;
- undocumented order of calls.
14. Hooks and Callbacks
Callback:
CaseCreatedHandler = Callable[[Case], None]
class CaseTracker:
def __init__(self, on_case_created: CaseCreatedHandler | None = None) -> None:
self._on_case_created = on_case_created
Use:
tracker = CaseTracker(on_case_created=lambda case: print(case.id))
Callbacks are simple but can become messy.
For multiple events, event bus pattern:
class EventHandler(Protocol):
def handle(self, event: DomainEvent) -> None:
...
Keep event contracts stable.
15. Avoid Global State
Bad:
DEFAULT_REPOSITORY = JsonCaseRepository(Path("cases.json"))
def create_case(title: str) -> Case:
return DEFAULT_REPOSITORY.create(title)
Problems:
- hard to test;
- environment-specific;
- thread/concurrency issues;
- hidden dependency;
- lifecycle unclear.
Better:
service = CaseService(repository)
service.create_case(title)
Provide convenience factory if needed:
def create_default_service(path: Path = Path("cases.json")) -> CaseService:
return CaseService(JsonCaseRepository(path))
Convenience should not hide core design.
16. Configuration API
Bad:
configure({"x": 1, "y": True, "z": None})
Better:
@dataclass(frozen=True)
class CaseTrackerConfig:
store_path: Path
enable_audit: bool = True
default_page_size: int = 100
Typed config helps:
- docs;
- IDE;
- validation;
- defaults;
- evolution.
For public libraries, avoid requiring huge config object for simple use.
17. Documentation as API
Docs are part of API.
Minimum docs:
- quickstart;
- install;
- basic example;
- configuration;
- error handling;
- extension points;
- API reference;
- migration/deprecation notes;
- troubleshooting.
Good quickstart:
from case_tracker import CaseService, JsonCaseRepository
repository = JsonCaseRepository("cases.json")
service = CaseService(repository)
case = service.create_case("Late reporting")
service.transition_case(case.id, CaseStatus.SUBMITTED)
If quickstart is hard, API is probably hard.
18. Examples as Tests
Examples should run.
Use doctest or normal tests.
Example test:
def test_readme_quickstart(tmp_path) -> None:
repository = JsonCaseRepository(tmp_path / "cases.json")
service = CaseService(repository)
case = service.create_case("Late reporting")
assert case.status is CaseStatus.DRAFT
Keep docs and tests aligned.
19. Type Hints as User Experience
Good library APIs are typed.
def transition_case(
self,
case_id: CaseId,
target_status: CaseStatus,
) -> Case:
...
Types help:
- autocomplete;
- static checking;
- docs;
- refactoring;
- protocol implementation.
Avoid exposing Any unnecessarily.
If using Pydantic/FastAPI at boundary, do not let framework-specific types leak into core API unless core API is framework-specific.
20. Backward Compatibility
Backward compatibility means old user code keeps working.
Breaking changes:
- remove function/class;
- rename parameter;
- change return type;
- change exception type;
- change default behavior;
- make optional argument required;
- change serialized format without migration;
- change public attribute type;
- make sync API async;
- change import path.
Non-breaking or lower-risk changes:
- add optional keyword-only parameter with default;
- add new method;
- add new exception subclass under same base;
- add new response field if clients tolerate;
- improve error message with caution;
- add new enum value if clients handle unknowns.
Compatibility depends on contract. If you did not define contract, users infer one.
21. Semantic Versioning
For library:
MAJOR.MINOR.PATCH
- MAJOR: breaking changes.
- MINOR: backward-compatible features.
- PATCH: backward-compatible bug fixes.
For internal packages, semver can still help.
But semver only works if:
- public API is defined;
- changelog exists;
- deprecations are communicated;
- tests cover compatibility.
Do not claim semver while breaking users in patch releases.
22. Deprecation
Deprecation is a transition period before removal.
Use warnings.warn.
import warnings
def old_create_case(title: str) -> Case:
warnings.warn(
"old_create_case() is deprecated; use create_case() instead.",
DeprecationWarning,
stacklevel=2,
)
return create_case(title)
stacklevel=2 points warning at caller.
Deprecation plan:
- Add new API.
- Keep old API working.
- Emit warning.
- Document migration.
- Wait appropriate releases/time.
- Remove in major version or announced release.
- Provide changelog.
23. Warning Categories
Common:
DeprecationWarning;FutureWarning;UserWarning.
For library user-facing deprecations, warning visibility depends on context. Tests can turn warnings into errors:
python -W error::DeprecationWarning -m pytest
Use warnings carefully. Too many warnings create noise.
24. Changelog
Keep changelog:
# Changelog
## 0.3.0
### Added
- Added `CaseService.transition_case`.
### Deprecated
- Deprecated `old_transition_case`.
### Fixed
- Fixed duplicate case id detection.
### Breaking
- None.
Changelog helps:
- upgrade decisions;
- incident investigation;
- compatibility review;
- user trust.
25. API Review Checklist
Before making API public, ask:
- Is name clear?
- Is common path simple?
- Are advanced options possible?
- Are defaults safe?
- Are parameters typed?
- Are boolean flags keyword-only?
- Is return type explicit?
- Are errors documented?
- Are partial failure semantics clear?
- Is dependency hidden or explicit?
- Is extension point stable?
- Is behavior testable?
- Is docs example short?
- Can this evolve backward-compatibly?
- What would users likely misuse?
26. Fluent API vs Plain API
Fluent:
query = (
CaseQuery()
.with_status(CaseStatus.SUBMITTED)
.assigned_to(reviewer_id)
.limit(100)
)
Plain:
query = CaseQuery(
status=CaseStatus.SUBMITTED,
assigned_to=reviewer_id,
limit=100,
)
Fluent can be nice for complex builder patterns. Plain dataclass is often clearer in Python.
Do not build fluent API just to look fancy.
27. Context Managers as API
For resources:
with CaseTracker.open("cases.db") as tracker:
tracker.create_case("Late reporting")
Implement:
class CaseTracker:
def __enter__(self) -> "CaseTracker":
self.open()
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
Use context managers for:
- DB sessions;
- transactions;
- file resources;
- temporary settings;
- locks.
Context manager makes lifecycle explicit.
28. Transaction API Design
Bad:
service.create_case(...)
service.save()
Unclear lifecycle.
Better:
with unit_of_work:
service.create_case(...)
or:
service.create_case(...)
where service owns transaction boundary.
For libraries, document:
- who commits;
- what happens on exception;
- whether operation is atomic;
- whether object remains usable after rollback.
29. Framework Design: Inversion of Control
Framework calls user code.
Library is called by user code.
Library:
result = library.do_work(input)
Framework:
@app.route(...)
def user_handler():
...
Framework design must define:
- lifecycle;
- hook order;
- error handling;
- dependency injection;
- extension points;
- configuration;
- plugin discovery;
- backwards compatibility.
Frameworks are harder to design than libraries because they own more control flow.
30. Plugin Design
Plugin interface:
class CasePlugin(Protocol):
name: str
def on_case_created(self, case: Case) -> None:
...
Plugin registry:
class PluginRegistry:
def __init__(self) -> None:
self._plugins: list[CasePlugin] = []
def register(self, plugin: CasePlugin) -> None:
self._plugins.append(plugin)
Questions:
- sync or async hooks?
- error isolation?
- hook order?
- plugin config?
- version compatibility?
- timeout?
- security?
- can plugin mutate domain object?
- are hooks transactional?
Plugin systems need careful contracts.
31. Error Isolation in Hooks
If plugin fails, should main operation fail?
Option A: fail operation.
for plugin in plugins:
plugin.on_case_created(case)
Option B: log and continue.
for plugin in plugins:
try:
plugin.on_case_created(case)
except Exception:
logger.exception("Plugin failed")
Option C: collect failures.
Document behavior.
For audit/security hooks, failure may need to fail operation. For optional notification hook, log-and-continue may be okay.
32. Package Layout for Public API
Internal layout:
case_tracker/
__init__.py
domain.py
service.py
repository.py
_internal.py
__init__.py:
from case_tracker.domain import Case, CaseId, CaseStatus
from case_tracker.errors import CaseTrackerError, CaseNotFoundError, InvalidCaseTransitionError
from case_tracker.service import CaseService
__all__ = [
"Case",
"CaseId",
"CaseStatus",
"CaseService",
"CaseTrackerError",
"CaseNotFoundError",
"InvalidCaseTransitionError",
]
This lets users:
from case_tracker import CaseService, CaseStatus
But avoid making import heavy. __init__.py should not connect database or load config.
33. __all__ and Discoverability
__all__ documents intended export names.
It affects wildcard import:
from case_tracker import *
Even if wildcard import is discouraged, __all__ helps communicate public API.
Keep it curated.
34. Module Names
Good module names:
domain
errors
service
repository
config
events
Bad:
utils
helpers
misc
manager
stuff
If using utils, be specific:
time_utils
json_utils
But often a domain-specific name is better.
35. Avoid API Surprise
Surprising behavior:
case = service.get_case(case_id)
case.status = CaseStatus.CLOSED
# silently persists?
Better:
- mutation explicit;
- persistence explicit;
- method documents side effect.
Example:
service.transition_case(case_id, CaseStatus.CLOSED)
For returned domain objects, document whether changes are tracked/persisted or detached copies.
ORMs often surprise users here. Service APIs should be explicit.
36. Stable Serialization Contracts
If your library emits JSON/data:
{
"id": "CASE-001",
"status": "DRAFT"
}
Clients may depend on field names.
Changing status to case_status is breaking.
Strategies:
- version schema;
- add field before removing;
- support aliases during transition;
- document stability;
- provide migration guide.
37. Testing API Compatibility
Add tests for public API imports:
def test_public_api_exports() -> None:
from case_tracker import CaseService, CaseStatus
assert CaseService is not None
assert CaseStatus.DRAFT.value == "DRAFT"
Test deprecation warning:
def test_old_create_case_warns() -> None:
with pytest.warns(DeprecationWarning):
old_create_case("Late reporting")
Test examples from docs.
38. Contract Tests for Extension Points
For repository plugin:
def assert_case_repository_contract(repository: CaseRepository) -> None:
...
All implementations must pass.
For event handler:
def assert_event_handler_contract(handler: EventHandler) -> None:
...
Contract tests help external/internal implementers.
39. Case Tracker Public API Proposal
from case_tracker import (
Case,
CaseId,
CaseService,
CaseStatus,
CaseTrackerError,
InvalidCaseTransitionError,
JsonCaseRepository,
)
Simple use:
repository = JsonCaseRepository("cases.json")
service = CaseService(repository)
case = service.create_case("Late reporting")
service.transition_case(case.id, CaseStatus.SUBMITTED)
Convenience:
service = CaseService.from_json_file("cases.json")
But avoid hiding too much.
40. Case Tracker Deprecation Example
Old API:
def create_new_case(path: Path, title: str) -> Case:
...
New API:
service = CaseService(JsonCaseRepository(path))
service.create_case(title)
Deprecation wrapper:
def create_new_case(path: Path, title: str) -> Case:
warnings.warn(
"create_new_case(path, title) is deprecated; use CaseService(JsonCaseRepository(path)).create_case(title).",
DeprecationWarning,
stacklevel=2,
)
return CaseService(JsonCaseRepository(path)).create_case(title)
Test warning.
41. Case Tracker Extension Points
Potential extension points:
| Extension | Interface |
|---|---|
| Repository | CaseRepository Protocol |
| Clock | Clock Protocol |
| ID factory | Callable[[], CaseId] |
| Event handler | DomainEventHandler Protocol |
| Authorization policy | CasePolicy Protocol |
| Serializer | CaseSerializer Protocol |
| Metrics | Metrics Protocol |
Do not add all at once. Add when requirement appears.
42. API Design Smell Checklist
Watch for:
- Public API too large.
- Underscore internals imported by examples.
- Mutable default arguments.
- Positional boolean flags.
- Return tuple with unclear fields.
- Generic
Exception. - Error without useful attributes.
- Global hidden repository/config.
- Function does I/O unexpectedly.
- Domain API depends on framework.
- Sync API secretly runs event loop.
- Extension point undocumented.
- Breaking change in patch release.
- Deprecation without migration path.
- Docs example does not run.
- Type hints missing from public API.
__init__.pycauses heavy side effects.- Plugin failure behavior undefined.
- User must understand internal storage format.
- Convenience API hides unsafe defaults.
43. Practice: Define Public API
Write case_tracker/__init__.py.
Decide exports:
Case;CaseId;CaseStatus;CaseService;JsonCaseRepository;CaseTrackerError;CaseNotFoundError;InvalidCaseTransitionError.
Add __all__.
Test public imports.
44. Practice: Improve Function Signature
Before:
def transition_case(case_id, status, actor, notify, audit, reason=None):
...
After:
def transition_case(
case_id: CaseId,
*,
target_status: CaseStatus,
actor_id: ActorId,
reason: str | None = None,
notify: bool = True,
audit: bool = True,
) -> Case:
...
Discuss whether notify/audit should be policy/config instead.
45. Practice: Add Deprecation Warning
Deprecate old function and test:
with pytest.warns(DeprecationWarning, match="deprecated"):
old_function()
Run tests with warnings as errors in CI periodically.
46. Practice: Write Quickstart
Create docs/quickstart.md with:
- Install.
- Create repository.
- Create service.
- Create case.
- Transition case.
- Handle errors.
- Run tests.
Then write a test that follows quickstart code.
47. Practice: Extension Contract
Define CaseRepository contract test. Run for:
- fake repository;
- JSON repository;
- SQLite repository if available.
Document contract in docs/repository-contract.md.
48. Self-Check
Jawab tanpa melihat materi:
- Apa itu public API?
- Apa itu internal API?
- Kenapa public API adalah janji?
- Apa itu surface area?
- Apa itu progressive disclosure?
- Kenapa defaults harus aman?
- Kenapa keyword-only parameter membantu?
- Kenapa positional boolean flag buruk?
- Kapan memakai result object?
- Bagaimana mendesain exception library?
- Kenapa root cause harus dipertahankan?
- Apa beda sync dan async API?
- Apa itu extension point?
- Apa risiko global state?
- Kenapa docs adalah API?
- Apa itu backward compatibility?
- Apa itu semantic versioning?
- Bagaimana deprecation yang baik?
- Kenapa
stacklevel=2penting? - Apa API smell paling berbahaya?
49. Definition of Done Part 032
Kamu selesai part ini jika bisa:
- Menentukan public exports.
- Menulis
__all__. - Mendesain simple common path API.
- Menyediakan advanced dependency injection path.
- Membuat defaults aman.
- Menggunakan keyword-only options.
- Menghindari positional boolean flags.
- Membuat result object.
- Membuat exception hierarchy.
- Mendokumentasikan extension point.
- Menulis deprecation warning.
- Menulis changelog entry.
- Menulis quickstart.
- Mengetes public imports.
- Menjelaskan backward compatibility impact.
50. Ringkasan
Library design adalah user experience untuk engineer.
Inti part ini:
- public API adalah janji;
- internal API harus jelas dibedakan;
- common path harus mudah, advanced path tetap mungkin;
- defaults harus aman dan tidak mengejutkan;
- keyword-only parameter meningkatkan clarity;
- boolean flags harus hati-hati;
- return type harus eksplisit dan evolvable;
- exception hierarchy adalah bagian dari API;
- root cause harus dipertahankan;
- extension points harus kecil, stabil, terdokumentasi, dan dites;
- global state membuat API sulit dites dan dioperasikan;
- docs dan examples adalah bagian dari API;
- backward compatibility harus dikelola;
- deprecation butuh warning, docs, migration path, dan waktu;
- framework design lebih sulit karena mengontrol lifecycle user code.
Part berikutnya membahas migration, modernization, dan maintaining Python systems.
51. Referensi
- Python Documentation —
warnings. - Python Documentation —
typing.Protocol. - Python Documentation —
dataclasses. - Python Packaging User Guide.
- Python Documentation —
__all__and modules. - Python Documentation —
contextlib.
You just completed lesson 32 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.