Module Structure
This guide defines the structure every backend module follows and the rules that make each module replaceable. A module can move to another distribution, or be replaced by another implementation, because callers import only surfaces the module deliberately publishes.
modules/activities is a composition namespace containing several independent
modules (activity, activity_laps, activity_streams, and so on).
modules/followers is the reference for a module that stays flat. A new module
follows one of these shapes; the existing modules are converted one at a time as
they are reworked (see Known debt).
Rules at a glance
The sections below are the authority; this is the part to keep in your head.
- A file's name is its role;
__init__.pyis a docstring and nothing else. - Peers call only another module's
integration_service,contracts,schema,constantsandevents. crudstages (flush);servicecommits and publishes. A surface write stages and its caller commits, unless listed.- Providers and
core.configare resolved at entry points and passed down. - Derived work is an event subscriber with a reconciliation net; delivery is at-least-once.
- How code must run (transactions, claims, threads, storage lifecycle, bounded scans) is in Runtime behaviour.
The two units
| Unit | What it is | Example |
|---|---|---|
| Composition namespace | Groups related modules and collects their routers, jobs and subscribers. It is not an implementation boundary. | modules/activities |
| Module | One aggregate or capability and the unit of extraction. | modules/activities/activity_laps, modules/followers |
A bounded context with one aggregate stays flat (modules/followers). Related
activity capabilities share the modules.activities namespace, but each child
package remains its own module. They are peers, not implementation friends:
activity_thumbnail may consume activity_streams.integration_service, but it
may not import activity_streams.service, CRUD, ORM models, signing, or helpers.
The root activity package owns the activity row and parent access decisions. It
does not own child tables merely because they refer to activities.id.
File roles
Filenames are part of the contract: the enforcement rules match on them, so a
file named crud.py is treated as persistence wherever it appears. The
corollary is that a misnamed file is silently exempt from every rule, which is
why a file's name has to be the truth.
A stem may qualify a role and inherits every rule that role carries:
summary_crud.py is a crud, summary_router.py is a router. A package that
needs a second persistence module names it <thing>_crud.py; it does not invent
a new word. The vocabulary lives once in tests/_helpers/module_roles.py.
Because the role is what the rules see, the role has to be the truth:
tests/architecture/test_module_boundaries.py asserts that only a crud-role
file executes statements against the session, that only router/public_router
files construct an APIRouter, and that only models files declare ORM classes.
The vocabulary is deliberately open (render.py and signing.py are legitimate
private helpers), so the check is that a file's content agrees with its name
rather than that its name is on a list. Ending a transaction (commit,
rollback) is a separate rule, stated under
Transaction ownership.
| File | Layer | Responsibility |
|---|---|---|
models.py |
persistence | SQLAlchemy ORM. The only place a table is declared. |
query.py |
persistence | Reusable SQL expression fragments. No session, no I/O. |
crud.py |
persistence | The only file that opens a Session against its own tables. Stages writes (flush), never commits or rolls back. Returns schemas/DTOs, never ORM rows. |
serializers.py |
persistence | ORM ↔ schema transformation, both directions. Called by crud at its edges. |
service.py |
application | Decides access, orchestrates, publishes events, owns transaction boundaries. |
router.py / public_router.py |
transport | Validate, delegate to service, return. No domain rule. |
dependencies.py |
transport | FastAPI DI and typed request-parameter aliases (Annotated[int, Path(gt=0)], enums); validation is declared on the type, not raised by hand. |
schema.py |
contract | Pydantic request/response shapes. Serialized to clients. |
contracts.py |
contract | Inter-module DTOs and typed seams. Never serialized. |
constants.py |
contract | Domain constants and mapping tables. |
events.py |
contract | Event-type names and validated payload models. |
event_publishers.py |
application | Publishes this package's domain events. |
subscribers.py |
application | Handles events. Registered from subscriber_registry. |
integration_service.py |
module surface | The curated set of operations other modules may call. |
contributors.py |
namespace contract | Typed callbacks installed by app composition for generic ingestion/profile orchestration. |
scheduled_jobs.py |
module surface | The module's recurring jobs, collected by the composition root. |
subscriber_registry.py |
composition surface | Registers the package's bus subscribers and durable handlers and declares its reconciliation nets; the entrypoints import this, never subscribers.py. |
contributor_registry.py |
composition surface | Process-local store of the contributors and resolvers that app/module_registry.py installs. Names no package. |
computation.py |
namespace helper | Pure computation shared by a namespace's packages. Owns no table and calls no module. |
model_registry.py |
composition surface | Declares the package's ORM model module without requiring core to scan domains. |
migration_service.py |
migration-only surface | The only domain surface app/migrations may import; never an ordinary domain dependency. |
Anything else (utils.py, signing.py, render.py, …) is package-private by
default. A helper that orchestrates other modules or ends a transaction is not a
helper: it plays the service role and is named <thing>_service.py, such as
activity_ingestion/pipeline_service.py.
Visibility
Four classes. This is the whole rule. Each file name appears in exactly one.
1. Module surface — importable by peers and outside modules
| Surface | Purpose |
|---|---|
integration_service.py |
Behaviour. Every cross-module call goes through it. |
contracts.py |
The data shapes that surface accepts and returns. |
schema.py |
Client-facing shapes, when a consumer genuinely needs the wire type. |
constants.py |
Stable domain constants. |
events.py |
Event names and payloads, so a consumer can subscribe. |
dependencies.py |
FastAPI DI, mounted at the HTTP boundary (transport code only). |
Every name on integration_service.py is a promise, so it holds only operations
with a real caller outside its package. Contributor plumbing
(_store_laps, _list_laps_for_activities) stays private; the contributor
factory is the surface. A new operation lands there with the caller that needed
it, or stays private until then;
test_the_surface_holds_only_operations_with_callers resolves callers by AST and
fails on the difference. A package nothing outside calls (activity_geocoding)
has no integration_service.py and is listed with its reason in
_NO_CROSS_MODULE_SURFACE.
A surface function that only forwards its arguments is not a layer either. When
integration_service → service → crud adds no decision, no access check and
no transaction, the surface goes straight to CRUD, which the matrix below
permits. What belongs in service is the arrangement of a write (stage,
publish, commit), not the act of forwarding. A read-only surface function calls its
own crud directly; a service function added only to forward is the layer this
paragraph rules out.
subscribers.py is private (class 4). A module with subscribers publishes
subscriber_registry.py (class 2) and the entrypoints import that, so
"registered in the API but not the worker" is a diff in one file, and the file
that holds the handlers can be split without touching the entrypoints.
modules.auth is the one module whose behavioural surface is not called
integration_service.py. It publishes identity_service.py (plus
dependencies.py), because its boundary is not "what may you call?" but "what
may you learn about the caller?" — a distinction the
auth boundary guide states and a dedicated auth-boundary
contract enforces structurally. The inconsistency is real and is the cost of
that: auth answers "what may I depend on?" with a different filename than
every other module. It is recorded here rather than smoothed over, and no other
module may follow it.
subscriber_registry.py is also where a module declares its reconciliation
nets. A durable subscriber derives state from an event, and delivery is
at-least-once but never guaranteed — a bus consumer can drop a message, and some
write paths publish no event at all. So each durable subscriber declares a
jasil.jobs.reconciliation.DurableSubscriberNet: either the scheduled backfill
that re-derives what the create path missed, or the reason none is needed.
tests/architecture/test_reconciliation_nets.py holds every module to it.
The DurableSubscriberNet type lives in the platform (jasil), not in a module,
so every module can declare nets without importing another bounded context for a
shared word.
A namespace-level file is appropriate only for composition or a genuinely
shared, dependency-free contract. modules/activities/computation.py is the
pure metric maths used by parsers and provider adapters; it owns no table and
calls no module. modules/activities/contributors.py defines the activity
ingestion/profile callback contracts (it depends only on the activity schema), while contributor_registry.py
stores only the contributors explicitly installed by app/module_registry.py.
Child packages return contributor objects from integration_service.py; package
imports never register themselves as a side effect.
2. Composition surface — imported only while assembling the application
router.py, public_router.py, subscriber_registry.py, scheduled_jobs.py,
and model_registry.py are collected by api.py, main.py, worker.py,
Alembic, or the namespace-level activities registries. Business modules do not
call them. scheduled_jobs.py also holds the job runners.
Every persistence-owning package declares its model module in
model_registry.py. app/model_registry.py collects converted modules
explicitly. Its filesystem scan exists only for unconverted legacy modules and
shrinks as those modules adopt this structure. core.database never discovers
or imports domain models.
3. Migration-only surface — imported only by app/migrations
migration_service.py exposes the operations a versioned data migration still
needs, and nothing else. It stops a migration reaching arbitrary CRUD, parser, render
or signing internals, and gives the migration one place to find the commit that CRUD no
longer performs. Import-linter forbids domain and core code from consuming it.
It is a boundary, not a snapshot: most entries alias live code, so changing a
parser or a CRUD function changes what a historical migration does. A data migration
that must stay byte-for-byte reproducible is rewritten in plain SQLAlchemy Core against
the tables as they were at that revision, and stops importing modules.*; the
wrapper is not a substitute for that.
4. Package-private — importable only from inside its own module
service.py, query.py, crud.py, models.py, serializers.py, utils.py,
signing.py, subscribers.py, event_publishers.py, and every unlisted helper.
Sibling activity modules are outside callers for this rule. A derived subsystem
that needs the activity row asks activity.integration_service, never
activity.service or activity.crud.
__init__.py holds a docstring and nothing else. A re-export facade would
hand out ORM models and CRUD functions under a package path, which is a silent
bypass of every rule above.
Allowed-import matrix
Read as row imports column.
| From ↓ / To → | own crud |
own service |
peer integration_service |
peer internals | peer schema/contracts |
other module |
|---|---|---|---|---|---|---|
router |
✗ | ✓ | ✗ | ✗ | ✓ | transport dependencies only |
service |
✓ | — | ✓ | ✗ | ✓ | integration_service only |
crud |
— | ✗ | ✗ | ✗ | ✓ | ✗ |
models |
— | ✗ | ✗ | ✗ | ✗ | ✗ |
subscribers |
✗ | ✓ | ✓ | ✗ | ✓ | integration_service/events only |
integration_service |
✓ | ✓ | ✓ | ✗ | ✓ | integration_service only |
app/migrations |
✗ | ✗ | ✗ | ✗ | ✓ | migration_service only |
Three entries deserve the reasoning:
crudnever calls another module. ASELECTthat first has to ask another bounded context a question is a service-layer decision wearing a persistence layer's clothes.modelsdo not import other models. A foreign key names a table with a string (ForeignKey("activities.id")). DatabaseON DELETEactions own lifecycle behavior. Unused bidirectional ORM relationships would force every optional package to be imported before any mapper could configure.integration_servicemay read its own module freely. It is the module, presenting a narrow face outward.
A face is not a layer, which is the one thing that entry does not license. A
write has an arrangement — stage the rows, publish the fact, own the commit that
makes those atomic — and that arrangement belongs to service. An
integration_service therefore never publishes events or ends a transaction: it
calls its own service for any write that decides, and
test_a_surface_does_not_publish_events forbids it from importing
event_publishers in every converted module. A surface that cannot publish cannot own the commit that has
to be atomic with the publish, so there is one write-orchestration layer, not two.
Layering inside a module
router / public_router → service → crud → query / models
↓
event_publishers → events
Downward only. crud never calls service; service never touches models.
Transaction ownership
service owns the transaction; crud only stages. The unit of work is known to
the layer that arranges it, not to the single statement a crud function runs.
| Layer | Does | Never does |
|---|---|---|
crud |
add / execute, then flush() so constraint violations and row counts surface at the call; refresh after the flush when it needs server defaults |
commit(), rollback(), a commit parameter |
service |
Stages through crud, publishes through event_publishers (passing db.commit so row and outbox event commit together), or calls db.commit() itself when no event is involved |
— |
integration_service |
Stages through its own crud for a decision-free write, or calls its own service for a write that decides or publishes |
End the caller's transaction, unless it is a listed unit of work; publish directly |
entry point (get_db, a job, a subscriber) |
Rolls back what its caller left open | — |
Two choices in that table are deliberate:
- No
commitflag. Acommit: bool = Trueparameter keeps two code paths in every writer, and its default is the wrong one:servicealways passedcommit=False, so thecommit=Truebranch was exercised only by tests. A writer that can end its own transaction also makes "stage, publish, commit" depend on each caller remembering to opt out. A writer that cannot, cannot be misused. cruddoes not roll back. A rollback decides the fate of everything the session staged, whichcruddoes not know. Acrudfunction never callsrollback()itself; it raises, and the transaction owner (core.database.get_dbon a request, the job or subscriber that opened the session elsewhere) rolls back.
One accepted exception: @handle_db_errors, which wraps crud functions,
rolls the whole session back on a database error and on an escaping
IntegrityError. That is the same decision made implicitly, and it is kept
only because unconverted modules still rely on it. A crud that translates
IntegrityError into a ConflictError itself is not rolled back by it. Do not
add new reliance on the decorator's rollback; the transaction owner is
responsible, and the decorator's rollback is removed when the last unconverted
module is converted.
Surface writes stage, the caller commits
A cross-module call must not end the caller's transaction. A caller that composes
two surface calls needs them atomic, and a module that can commit another
module's session can persist half of an operation it does not own. So a surface
write stages (flush) and its caller, which holds the session and knows the
unit of work, commits. set_thumbnail_key, clear_all_thumbnail_keys and
set_activity_location work this way; their callers (the thumbnail and geocoding
services) commit.
The exception is a surface operation that is a self-contained unit of work, and the reason is always one of two:
- It publishes an event that has to be atomic with the change. The platform
publisher takes the commit callable because in the durable path the outbox row
is staged in the transaction and committed with it, and in the best-effort path
the commit must come before the dispatch. A surface that publishes therefore
owns the commit ordering, through its own
service. - It owns a rollback: a race on a unique key leaves the session unusable until it is rolled back, so the write must roll back before it looks up the winner.
Each such operation is listed in _SURFACE_UNITS_OF_WORK with its reason.
TestSurfaceTransactions follows every surface function through its own
service and helpers and fails on one that ends a transaction without being
listed, and on a listed one that no longer does.
Decision-free surface writes
An integration_service function may stage through its own crud only when the
write makes no decision: no access check, no invariant beyond payload shape, no
event and no commit. The contributor persist and restore callbacks are the
case this exists for. The caller (ingestion in activity, the profile importer)
owns the unit of work, so a service function between them could only forward
and stage, which is a layer that adds nothing.
The moment such a write needs an ownership check, a business rule, an event or a
commit, it moves to service. The surface never grows a second write path beside
it.
A failed commit() is not translated in service. service does not wrap its
commit in try/except, and it does not catch SQLAlchemyError. The error
propagates, core.database.get_db rolls the session back, and
core.exceptions.database_error_handler — registered once with the other
handlers — renders the same opaque 500 problem document a ProcessingError
produces. Translating at one boundary keeps the rule in one place instead of one
try per service, and keeps the SQL statement and its parameters out of both the
response and the log message (OWASP A09). A service catches a database error only
to make a domain decision from it — mapping IntegrityError to ConflictError
is the precedent — never to re-raise a generic one.
test_crud_does_not_end_transactions enforces it: a crud-role file in a
converted module may not call commit or rollback. Files that still do are
listed in _CRUD_TRANSACTION_DEBT, and the test fails on an entry that no longer
offends so the register shrinks rather than lingers.
What a module may depend on
core.*— cross-cutting primitives (logging, config, pagination, exceptions, timezone, signing, uploads). Always allowed.jasil.*— the platform substrate, an external package. Through its public surface only:jasil.providers(the capability ports),jasil.publisher,jasil.events,jasil.subscribers,jasil.jobs.registry. Neverjasil.backends(the adapters),jasil._core, or acrudmodule.- Other modules — through their
integration_serviceonly. fastapi— routers and dependencies only. Persistence raisescore.exceptions; the app's error handler maps those to responses.
Providers are resolved at the edge and passed down. A router declares
Depends(core.dependencies.get_storage); a subscriber, scheduled job or
integration_service surface function (callers with nothing to carry a provider)
calls jasil.runtime.get_active_platform() once and hands the provider to
service. A service, crud or helper never resolves it itself:
TestProviderResolution fails on get_active_platform below an entry point, so
a dependency is visible in the signature and a test substitutes it without
patching the runtime.
The dependency direction is fixed: core and the substrate must never import
modules. A module publishes its scheduled work (scheduled_jobs.py), its
subscribers (subscriber_registry.py) and its plug-ins; the platform collects
them. It is never the platform's job to know a module exists.
Because the substrate is an external package, that rule is split across two
enforcement points. Its own internal direction (ports never importing adapters,
nothing importing modules) is an import contract in the JASIL repository,
verified on its releases. Which part of it this application may touch lives in
tests/architecture/test_jasil_boundary.py — import-linter cannot state it,
because it rejects a subpackage of an external package as a forbidden module.
Extraction target
"Could become its own library" means a specific thing here, and it is worth stating so the next module is converted to the same target:
- In scope: a module is a package that could be installed separately and run in the same process against the same database. Its only inbound surface is the published one; it declares its own models, jobs, subscribers and contributors; and a peer can be lifted out without editing another module's internals.
- Accepted, not accidental: surface operations take a SQLAlchemy
Session(list_accepted_followee_ids(user_id, db)). Callers share one transaction, which is what makes "stage, publish, commit" atomic across a module boundary. This is also why a module cannot yet move to another process. - Out of scope: swappable implementations behind Protocol ports. Use a port
only for a seam that genuinely has several implementations — providers,
geocoding, file storage — and let the consumer own the Protocol. Everywhere
else the concrete peer
integration_serviceis the contract. - Still shared: the metadata (
core.database.Base) and the central Alembic history. A module will need its own migration branch before it can be distributed on its own.
Choosing a coupling mechanism
| Need | Mechanism | Example |
|---|---|---|
| Ask a peer a question, or read its data | Call its integration_service |
followers.integration_service.list_accepted_followee_ids |
| A write that must be atomic with another module's write | A service call in the same session; the owning module's service arranges stage, publish and commit | activity bulk gear update |
| React to a fact, derive state, tolerate delay | Publish an event; the consumer subscribes; declare a reconciliation net | thumbnails and geocoding reacting to activity.created |
| A lower layer needs something only a higher layer knows | A seam (registry, base class, Protocol) owned by the lower layer, installed by composition | contributor_registry, provider_registry |
Do not use a contributor for a plain query, and do not use an event for something the caller needs the answer to.
Configuration is read by the application layers
core.config is the application's settings, which makes it a dependency like a provider:
it has to be visible in a signature, not discovered in a function body. A converted module
therefore reads it only in the layers that decide (service, router, public_router,
dependencies, subscribers, scheduled_jobs, integration_service,
migration_service). Every other file is a pure layer, and a value it needs is a
parameter: the file parsers take the fallback default_timezone, the thumbnail renderer
takes a user_agent, and a BulkImportSource that needs a directory names none and lets
the entry service apply the default. TestConfigurationReads fails on a core.config
import anywhere else, so the rule holds for a file added next year.
What a module may take from core
core.* is "always allowed" today, and it is the largest hidden coupling between
a module and this application: configuration, logging, database session,
exceptions, pagination, timezone, uploads. It is not enforced yet. Treat it as a
declared dependency rather than free: prefer a primitive that is already generic
(core.exceptions, core.timezone, core.pagination) over adding a new reach
into application-wide state (core.config, core.middleware), and put anything
generic enough to be shared by other products into jasil.
For converted modules this is an allowlist:
tests/architecture/test_converted_module_dependencies.py names every core
module activities and followers may import, with the reason, and fails both
on an import that is not listed and on an entry nothing uses any more. Unconverted
modules are outside it.
Inverting a platform-to-domain reach
When the platform appears to need something from a module, the module registers it rather than the platform importing it. Three worked examples live in the tree:
| Reach | Inversion |
|---|---|
| The scheduler needed each module's recurring jobs | Each module declares scheduled_jobs.recurring_jobs(); main.py collects and hands the list to start_scheduler. |
| Ingestion needed to pull from Strava and Garmin | Providers call activity_ingestion.integration_service.register_activity_provider; the private registry names no provider. |
| Bulk import needed Strava-export semantics | BulkImportFormat is a registered layout (which members are activity files, the drop folder, how to build the source); modules.strava.bulk_import_source registers one and StravaBulkImportSource subclasses BulkImportSource. |
| Activity ingestion/profile restore needed child records | Each child returns typed contributor objects from integration_service; app/module_registry.py installs them in activities.contributor_registry. |
| Returning an activity needed its thumbnail's URL | activity.service asks contributor_registry.resolve_thumbnail_url for each activity it hands a client; activity_thumbnail registers the resolver at composition time. The stored key never leaves the server. |
The shape is always the same: the lower layer owns the seam (a registry, a base
class, a protocol), the higher layer owns the implementation, and a composition
root (main.py, worker.py) connects them. A registration performed in one
entrypoint must be performed in the other — a provider or subscriber registered
in the API but not the worker silently does nothing wherever its work is claimed.
Sharing behavior between peer modules
When several modules perform the same operation over different rows, the
operation lives once in its owning module and peers consume its public behavior.
activity/child_collection_service.py is the worked example: the root activity package
owns the parent access rule and publishes ChildCollection through
activity.integration_service. Laps, sets and workout steps state their hide
flag, CRUD calls and page type without importing the private helper file.
The test for whether something belongs in such a seam is not "is this repeated?" but "would a divergence here be a bug?". Three copies of a docstring are harmless; three copies of an access decision are three chances to get it wrong.
activities/contributors.py is the second worked example:
child_ingestion_contributor and child_profile_contributor hold the parent-id
guard and the re-keying filter that laps, sets and workout steps share. Each
child supplies only its CRUD write, its archive names and how one archived record
becomes a stored row.
Runtime behaviour
The import and layer rules say where code lives. These say how it must run, in one process and across replicas alike. Tests do not enforce them yet, so review does.
- No transaction across external I/O. A service that has read rows and then calls the
network (tile server, geocoder, provider API), renders, parses a file or writes to object
storage ends the read transaction (
db.commit()) first. An open transaction pins a pool connection and a snapshot for as long as the call takes, and the pool is shared with every request. - Claim work with one conditional
UPDATE. Delivery is at-least-once, so a job or batch is claimed withUPDATE … WHERE status = 'pending' OR the claim is older than the leaseand the caller checks the row count; read-then-write is a race. A failed attempt returns the job topendingbefore re-raising, so the retry can claim it (activity_ingestion.crud.mark_processing,followers.crud.accept_follower). - Subscribers are cheap, idempotent and run off the request. On the in-process bus they run
on a small background pool (
app/module_registry.py); on Redis, on the consumer thread. Either way a subscriber opens its own session, assumes it may run twice or not at all, and relies on its reconciliation net. Nothing may assume a subscriber finished before the response. - Threads inherit context. Work handed to a pool runs under
contextvars.copy_context(), so the request id reaches its logs and events. - Every storage area has an owner, a lifecycle and an erasure path. The domain that writes an area states who deletes its blobs when the parent goes, which sweep catches what an event missed and, for user data, how account deletion removes it. An area missing one is incomplete.
- Maintenance scans are bounded. A scheduled pass pages by id with a batch constant or lists storage by prefix in batches; it never loads a whole table or area into memory.
- Every outbound call has a timeout, and a request path never waits on a lock or the pool longer than its client would.
Enforcement
Two mechanisms, deliberately:
backend/.importlinter — what import-linter is good at: external packages
(redis, requests, fastapi in the parsers), the platform direction
(core never imports modules), the auth boundary, the migration surface and a
few named design seams. Run in CI by .github/workflows/test-backend.yml:
cd backend && PYTHONPATH=app uv run lint-imports
It matches exact module names, so it is not where layer rules live: a
qualified stem (pipeline_service) escaped a name-based contract, and a flat
module needed its own copy. Those rules are stated once, by role, as allowlists,
in TestLayerRules below.
backend/tests/architecture/test_module_boundaries.py — everything
import-linter cannot express, including the layer rules. A forbidden contract rejects an import if any
source matches any forbidden module, so "a package may import its own crud
but not a peer's" is not statable: the wildcard pair
modules.activities.* → modules.activities.*.crud also rejects
activity_laps.service → activity_laps.crud. The conformance test walks the AST,
compares each import against the importer's own package, and consults an explicit
allowlist containing only composition edges and exact read-model projections.
TestLayerRulesstates each layer rule once, by file role, for every converted module: only persistence (models,crud,serializers,query) imports an ORMmodelsmodule; a router never reachescrud,models,queryorevent_publishers; persistence imports no FastAPI and asks no other package (only its data shapes); a converted module reaches another top-level module only through itsintegration_service,schema,contracts,constants,eventsordependencies; and a lower module (followers) never imports the one above it. They are allowlists, so a new file is covered the day it is written._LAYER_EXCEPTIONSis the register of deliberate seams, each with its reason.- It checks module-level files too, not just sub-package ones, which is the whole
surface of a flat module such as
followers. - It resolves each
from x.y import zagainst the filesystem rather than counting dots, so a symbol imported from a flat module is not read as a package reach. - The allowlist is the debt register. Every entry names a real cross-package reach and why it exists, and may not be used for service, CRUD, model, query, signing, parser or storage access that belongs behind an integration surface.
- The package-level import graph inside a module is asserted to be a DAG: two packages that import each other are one package pretending to be two. A derived subsystem depends on the thing it derives from, never the reverse; when the depended-on package needs something back, it states a seam and composition installs the answer.
TestModuleInventoryfails on a top-level module that is neither converted nor explicitly outstanding.test_only_transaction_owners_end_transactionsis the commit rule as an allowlist: onlyservice,migration_service,subscribersandscheduled_jobsfiles may end a transaction, so a file named for no role is not exempt.
backend/tests/architecture/test_logging_rule.py and
test_route_conventions.py state the logging, route-declaration,
rate-limit and db-position rules by what the code does, over every converted
module. Where a rule needs an exception it is a register with a reason that fails
when it goes stale, not an exemption in the rule.
Adding a module
- Create either
modules/<name>or a package under a composition namespace such asmodules/activities/<name>;__init__.pycontains a docstring only. - Add
integration_service.py. It is the sole behavioral import path for peers and outside modules; keep it limited to operations with real callers. A package nothing else calls (activity_geocoding, purely reactive) has none and is listed with its reason in_NO_CROSS_MODULE_SURFACE. - Add
model_registry.pywhen the module owns a table, and add its contribution to the namespace/application model registry. - Add
migration_service.pyonly when an existing data migration needs a domain operation; expose that operation and no more. - Add
subscriber_registry.pyif the module has subscribers, andscheduled_jobs.pyif it has recurring work. Register both with oneRuntimeModule(name=..., ...)entry inapp/module_registry.py.main.pyandworker.pyboth consume that list, so registering "in the API but not the worker" is not possible. - Add startup backfills as
StartupTasks on the sameRuntimeModuleentry. - For an activity child parsed or archived as JSON, add the applicable
ingestion_contributor,profile_contributor, orprofile_global_contributorfactory tointegration_service.py, then register that factory inapp/module_registry.py. Do not register at import time. - Add the module to the source lists of the cross-module contracts in
backend/.importlinter. - Add a new top-level bounded context to
CONVERTED_MODULESinbackend/tests/_helpers/module_roles.py; the architecture and logging tests both read it from there. A module that is in neitherCONVERTED_MODULESnorUNCONVERTED_MODULESfailsTestModuleInventory, so a module cannot be added outside the rules by omission.
Conventions that look like drift
These differ between packages on purpose, or are settled so the next module follows one pattern. Recorded here so a review does not reopen them.
Settled (follow these in a new module):
- Event publishers.
publish_<fact>(<ids>, db, *, commit, <extras>):dbandcommitare required andcommitis keyword-only. A publisher with an optionalcommithas a best-effort path that lets a caller publish outside the transaction, which is whatpublish_committingexists to prevent. - Import aliases. An alias is the containing package plus the role, so the
name says whose file it is and which side of the visibility rule a call site is
on. A peer's surface is
<package>_integration(activity_laps_integration,followers_integration,activity_integrationfor the rootactivitypackage); a private module is<package>_<role>(activity_crud,activity_laps_service). A namespace-level file lives inactivities, so it isactivities_<role>(activities_contributors,activities_computation). - Capability-token routes. The service verifies the signed token and raises
NotFoundError; the router only serialises the bytes. A router never importsHTTPExceptionor a logger. - ORM mapping.
serializers.pyexposesto_read_schema(andto_ormwherecrudbuilds rows). A private_to_*helper insidecrud.pyis the same job in a second home. dbparameter. Inservice,crud,integration_serviceand every other non-framework file,dbis the last required positional parameter and keyword-only options follow it. Routers anddependenciesare exempt: FastAPI resolves their parameters by name, so they order them by dependency (test_db_is_the_last_required_positional_parameter).- Private helper docstrings. A
_privatefunction gets a summary line, and anArgssection only for a parameter whose meaning is not obvious from its name and type (neverdb: Database session.). Public functions document every parameter. - Helper file names.
<thing>_utils.py, neverutils_<thing>.py, so the role reads at the end of the stem like every other role. - Route defaults. The default
200is not repeated instatus_code. Handler names are OpenAPIoperationIds, so renaming one is an API change. - Route declarations. A JSON route declares
response_model; a route that returns bytes declaresresponses=(orresponse_class) with its media type. A write (POST/PUT/PATCH/DELETE) names its rate-limit tier; reads take the global default (TestRouteConventions). - Comments and docstrings. They state what the code does now and why. History (what a file "used to" do, a ratio of removed duplication, a refactor's motivation) belongs in the commit message or PR, where it can be dated and corrected, not in the source, where it goes stale.
- Logging. A service function that refuses a caller (
PermissionDeniedError) or ends a transaction logs in the same function; a service that only delegates has no such function and logs nothing. A file with no role logs only when it is listed with its reason in_HELPERS_THAT_LOG, andconsole=Trueis for bulk-import progress and migrations (test_logging_rule.py). - Read models. The schema a client receives as a response body is
<Thing>Read(or<Thing>Page); the ORM class keeps<Thing>. A schema and an ORM class never share a name, and the OpenAPI component name is the schema's. A value object nested inside a read model (SummaryMetrics,DaySummary) keeps its descriptive name. - Read models are stored rows. A
<Thing>Readthat carries anidrequires it: it is what the server returns, never what a producer sends. A read model identified by a natural key (FollowRelationshipRead: follower and followee) has noidand needs none. A shape that is written before it has an id (parsed, archived or restored data) is<Thing>Createincontracts.py, and both derive from a shared<Thing>Baseinschema.py(activity_sets,activity_workout_steps,activity_exercise_titles). An optionalidon a read model is the symptom of one class doing both jobs. - Status vocabularies. A closed set a column stores (
FollowStatus,IngestionJobStatus) is aStrEnuminconstants.py, imported bymodels,crud,serviceandschemaalike.schema.pyonly references it as a field type. - Page arguments.
page_numberandnum_recordsfollowdbas keyword-only arguments, withcore.pagination.DEFAULT_NUM_RECORDS/DEFAULT_CHILD_NUM_RECORDSas the defaults, never a literal. - Access decisions are service. A helper that decides what a caller may see and
logs the refusal (
activity/child_collection_service.py) is named<thing>_service.py, even when other services consume it. - Request parameters. A path or query parameter's constraint is part of its type
(
core.dependencies.PositiveId, an enum for a closed set,AfterValidatorfor a vocabulary lookup), so FastAPI validates it, answers with the validation problem document and publishes the accepted values in OpenAPI. Adependencies.pydoes not raiseHTTPException, and a child router declares the shared alias (activity_dependencies.ActivityId) on its own handlers rather than mounting a dependency whose only job is to carry the parameter. - Refusals answer 404, and say so in the log. Reading someone's follower network or
deleting a follow relationship you are not part of is answered as not found, the same
as an activity the caller cannot see, so an endpoint cannot be used to probe who exists
or who follows whom.
PermissionDeniedErroris for a caller who is entitled to know the resource exists but may not change it. What the server logs depends on what the code knows:
| The code knows | Raises | Logs |
|---|---|---|
| It is refusing a caller (bad capability token, a network the caller may not view) | AccessConcealedError (renders as the same 404) |
WARNING in the same function |
| The caller is entitled to know the resource exists | PermissionDeniedError |
WARNING in the same function |
The record is missing, or a missing record cannot be told from a refusal (an empty page, owns_activity is false) |
NotFoundError or an empty page |
DEBUG at most |
test_a_service_decision_is_logged enforces the first two rows: a service function that
raises either refusal logs at WARNING in that same function.
- Request bodies. A shape a client sends is named for what it does (<Thing>Edit,
<Thing>Create, FollowRequestAccept) and lives in schema.py; the same shape fed by
a parser or an archive rather than a client is <Thing>Create in contracts.py. A
<Thing>Read is never accepted as input (TestReadModelsAreStoredRows).
- Collections. A list a client receives is a core.pagination.Page; a
collection that is absent is [], never None. The two exceptions are the
exercise-title reference table (/all, read whole by design) and the response
to a bulk import (the handles of the jobs that POST created).
None is kept only where it is a distinct signal: the ingestion store
functions return None for "this file failed" and a list for "it parsed".
Intentional variation:
migration_service. Pure reads and constants are re-exported by alias; every write is a wrapper that owns the commit the CRUD no longer performs, so a migration's unit of work is stated in one function.- Component payload shape. The ingestion contract carries what each parser
emits: laps as dicts, sets as raw FIT rows or
ActivitySetsCreate, workout steps as schemas. Normalising them is a change toParsedActivity.componentsand every parser, not to the children, soserializers.to_ormabsorbs the difference at the persistence edge. - Job runners own their sessions.
run_upload_job,run_refresh_job,run_bulk_import_jobandfail_ingestion_jobopenSessionLocalthemselves: they are the entry point of a durable job and need separate transactions (mark processing, do the work, record the failure) so a failed unit of work cannot poison the record of the failure. - Provider resolution on a surface. A surface function resolves the provider
only when none of its callers carries one (
attach_media_bytes, called per file from the Strava import). The functions whose callers already hold the provider (list_media_blobs,store_media_blob, the file-storage functions) take it as an argument. - Contributor helper names and
contracts.py. The plain child collections (laps, sets, workout steps) sharechild_*_contributor, so each has_store_*,_list_*_for_activitiesand_parse_profile_record. Streams, media and exercise titles persist or restore something that is not a row-per-activity list, so their private helpers are named for that (_persist_ingestion_component,_restore_profile_records). A package has acontracts.pyonly when it defines an input shape itsschema.pydoes not (activity_sets,activity_streams,activity_media,activity_workout_steps,activity_exercise_titles); laps hand their contributor plain dicts, so they have none. activity_file_storagehas noservice.py. It is a leaf overStorageProviderwith no decision to arrange, so a service would only forward.
Known debt
Tracked here so it is visible rather than discovered. Each item is enumerated in the conformance test's allowlist.
Runtime rules not yet met
- Storage lifecycle. The
bulk_import_errorsarea has no sweep, retention or account-deletion removal, and the thumbnail reconciliation lists its whole area each pass. - Timeouts. The pool (20 + 40, 180 s wait) and the absence of a
statement_timeoutare constants incore/database.py, not settings, so an operator cannot bound them. - Observability. Logs are the only signal; queue depth, job duration and dead-letter counts are not exported as metrics.
- Visibility across modules. The followee set reaches
activity.crudas an id list, which grows with the follow count. A peer-published, read-only query fragment (a followee subquery) would avoid the list but needs its own allowed-import entry and test, so it is undecided.
Pilot status and what the next conversion owns
activities and followers are the only modules converted so far. The
remaining modules are listed in UNCONVERTED_MODULES so that
TestModuleInventory keeps them visible rather than silently exempt, and are
converted one at a time with the convert-module skill. The items below need
changes outside the two converted modules, so they are recorded here as accepted
limitations rather than half-done:
- Top-level cycles.
users⇄activities(profile export/import consumes activities; activities consumesusers.integration_service) and, by the contracts' own description,gears⇄activities. The planned fix is the contributor pattern the activity children already use:users_profileexport/import asks each module for its section instead of importing them. Alayerscontract (core, identity, followers/gears/health, activities, providers) then states the module-level direction once. Every edge that closes a cycle lives inusersorgears; the activities side already publishes the contributor surface, so the cycles stay until those modules are changed. - Layer rules for unconverted modules.
TestLayerRulescovers converted modules only. An unconverted module is outside it until converted, and theauth-boundarycontract is the one generic rule over every module:TestAuthBoundaryCoveragefails when its source list differs from the set of top-level modules, so a new module cannot start unconstrained. - Registration seams.
RuntimeModuleinapp/module_registry.pyis the per-module manifest for subscribers, durable handlers, nets, recurring jobs and startup tasks. Routers (api.py) and models (model_registry.py) stay separate composition surfaces on purpose: they have different consumers (the HTTP app and Alembic), and the legacy model scan keeps the model seam distinct until the unconverted modules are gone. - Namespace-level code in
health.healthhasrouter.py,schema.pyandutils.pybeside its sub-packages, which conflicts with "a composition namespace is not an implementation boundary". -
authnaming.identity_service.pyand_internal/instead ofintegration_service.pyand the filename vocabulary, as described above. The generic boundary rule needs a special case until it is aligned. -
Persistence layers calling other modules. Converted modules ask nothing of another bounded context below
service(TestLayerRules). The unconverted ones still do, for examplehealth.*.crudreachingusers.users.utilsandnotifications.utilsreachingusersandwebsocket. - Persistence ending transactions. Converted modules stage with
flush()and letservice(or the job, importer ormigration_servicethat owns the unit of work) commit. Unconverted modules (auth,users,gears, …) are outsidetest_crud_does_not_end_transactions;_CRUD_TRANSACTION_DEBTis empty and is where a module being converted lists its files. - Subscribers reaching past a surface. Followers holds no subscribers; its
notification handlers live in
modules/notificationsand subscribe tofollowers.events(TestLayerRules).garmin,strava,auth.sign_up_tokensandusers.users_profilestill import another module'sutils/managerdirectly. - Legacy model scan. Converted modules declare their ORM module in
model_registry.pyand use scalar foreign keys with databaseON DELETEbehaviour.app/model_registry.pykeeps a filesystem scan only for modules not converted yet.