Lesson 12 — organize your project¶
What you will build¶
Nothing new — this lesson is about where code goes. There is one convention, and it decides almost everything else: only the composition root imports the framework. A service module is ordinary Python, which is why it can be tested without an application, an event loop, or a database.
What you need¶
The setup from lesson 1. This lesson uses nothing beyond the core package.
The layout¶
myapp/
xyberos.toml settings, read once by the application
src/myapp/
__init__.py
app.py the composition root: create_app()
services/ ordinary Python: no framework imports
handlers/ route handlers: framework types allowed, no business rules
tests/
test_app.py
Three rules make it work, and they are the layout's whole content:
| Rule | Why |
|---|---|
| Services import nothing from the framework | a service can then be constructed and asserted on in one line, with no application |
| The composition root imports everything | there is exactly one place to read to learn what the application is made of |
| Handlers translate, they do not decide | a handler turns a request into a service call, so the rule it enforces is testable without HTTP |
The code¶
A service module first — note what it imports:
"""The service module lesson 12 inspects.
It imports nothing from Xyberos, and it does not need to: the framework appears in a composition
root and nowhere else. That is what makes this module testable with no application, no event loop,
and no plugin — and it is the single convention the rest of the layout follows.
"""
from __future__ import annotations
class InvoiceLine:
"""One line on an invoice."""
def __init__(self, description: str, cents: int) -> None:
self.description = description
self.cents = cents
class InvoiceFormatter:
"""Turn an amount in cents into the line a person reads."""
def format(self, cents: int) -> str:
"""Return the amount as dollars and cents."""
return f"${cents // 100}.{cents % 100:02d}"
And the lesson's own example, which checks that claim rather than asserting it:
"""Lesson 12 — the one convention that decides where code goes.
Only the composition root imports Xyberos. Everything else — services, repositories, formatters — is
ordinary Python, which is what makes it testable without an application. This module checks that
claim against a real service module rather than asserting it in prose: it reads the source and
reports what it finds.
"""
from __future__ import annotations
import inspect
from learn_fundamentals import fundamentals12_service as service
def main() -> None:
"""Report what the service module imports, and use it with no application in sight."""
source = inspect.getsource(service)
print("the service module imports xyberos:", "xyberos" in source)
defined = sorted(
name
for name, value in vars(service).items()
if inspect.isclass(value) and value.__module__ == service.__name__
)
print("it defines:", ", ".join(defined))
print("and it works on its own:", service.InvoiceFormatter().format(1234))
if __name__ == "__main__":
main()
How to run it¶
What it prints¶
the service module imports xyberos: False
it defines: InvoiceFormatter, InvoiceLine
and it works on its own: $12.34
The first line is the convention, checked against the source rather than promised. The third is the consequence: no application was created, no provider was registered, and the service still worked.
Common mistakes¶
- A service that takes the
App. It can then resolve anything, which means it declares nothing — and the layering rule is gone in a single constructor argument. - Business rules in a handler. The rule is then only reachable through HTTP, so testing it needs a client, a route, and a running application.
- A service that reads configuration or the environment. Pass the value in; the application is the only thing that reads settings (lesson 7).
- Putting the composition root in
__init__.py. Importing the package then composes an application as a side effect, which breaks every test that imports anything from it.
Exercise¶
Move InvoiceFormatter into a services/ subpackage of your project and add a test that uses it
without an application.
Show the answer
The move needs no change to the class, which is the point of the convention: a file that imports nothing from the framework can be relocated without being rewritten. The test needs no `App` either: the whole assertion is `InvoiceFormatter().format(5) == "$0.05"`, and it passes with no fixture, because there is nothing to set up. If that test needs a fixture to construct the class, the class has acquired a dependency it should not have — and the layering rule is what makes the problem visible this early.Next¶
Lesson 13 writes the test file for a project like this one, and lesson 14 is about the errors you will meet while doing it. Until it is published, the roadmap shows where each lesson stands.