Project Structure
Oridecon does not ask you to pick minimal, structured, or modular up front. oridecon new project lays down one tree. A project that never draws a bounded context simply never has a modules/<slug>/ directory. Adopting one later is not a migration — only the nodes you scope into it move.
There is no --structure flag and no [tool.oridecon] structure key.
Two questions decide every path
Section titled “Two questions decide every path”- Is this component cross-cutting? Errors, middleware, providers, health, schema — one per application. They land in
src/<app>/shared/<component>/and stay there, whether or not the node belongs to a module. - Is this node in a module? A module-local component lands in
src/<app>/<component>/while the node is unscoped, and insrc/<app>/modules/<slug>/<component>/the moment it joins a module.
The composition root is always src/<app>/app.py. The ASGI target is always <app>.app:app ([tool.oridecon] module in pyproject.toml).
shared/ means cross-cutting. Unscoped feature code sits at the app package root, not in shared/, so nothing has to be moved out of shared/ later.
Day one — what the scaffold lays down
Section titled “Day one — what the scaffold lays down”oridecon new project my-app --template web-apiTemplates (minimal, api, web-api, graphql, worker, full) change which packages and application.yaml sections you get. They do not change the tree.
my-app/├── application.yaml├── pyproject.toml # [tool.oridecon] module = "my_app.app:app"├── README.md├── .env.example├── migrations/versions/ # oridecon gen migration├── seeds/ # oridecon gen seeder├── src/│ └── my_app/│ ├── __init__.py│ ├── app.py # create_app() — composition root│ ├── py.typed│ ├── controllers/ # unscoped; oridecon gen controller …│ ├── infrastructure/ # db, cache, events│ ├── shared/ # cross-cutting packages (empty until generated)│ └── modules/│ └── __init__.py # empty until oridecon new module└── tests/ ├── conftest.py # boots create_app() └── test_app.pyA fresh project ships no sample module. Feature directories such as services/, domains/, and di/ appear when you generate them.
oridecon gen controller users # src/my_app/controllers/…oridecon gen service greetings # src/my_app/services/…oridecon gen error not_found # src/my_app/shared/errors/… (always shared)Growing a bounded context
Section titled “Growing a bounded context”When a feature needs an encapsulation boundary — private services, a public protocol, its own provider — add a module. The rest of the project stays put.
oridecon new module auth# → src/my_app/modules/auth/{__init__.py, protocols.py, provider.py, services.py}# → list AuthModule in create_app() next to WebModuleThen generate into it:
oridecon gen controller users --module auth# → src/my_app/modules/auth/controllers/…--module is a per-invocation fact, never project state. You do not convert the app. You scope a node.
src/my_app/├── app.py # create_app() — the composition root├── controllers/ # unscoped feature code├── domains/ # top-level domains├── di/ # app providers (`*_provider.py`)├── services/├── infrastructure/ # db, cache, events├── shared/ # cross-cutting (see below)└── modules/ ├── __init__.py # AuthModule lives here └── auth/ ├── __init__.py # @module AuthModule ├── protocols.py # the contract other modules import ├── provider.py # AuthProvider (register/boot/shutdown) ├── services.py ├── controllers/ # the same components, now module-local ├── domains/ # module-level domains ├── repositories/ └── tests/ # oridecon gen test --module authWhere files land
Section titled “Where files land”Cross-cutting (src/<app>/shared/<component>/, --module ignored):
audit, errors, features, filters, health, interceptors, mcp, metrics, middleware, providers, schema, schema/dataloaders, search, storage/backends, tenancy, vector/collections
Module-local (src/<app>/<component>/ → src/<app>/modules/<slug>/<component>/):
controllers, domains, services, repositories, commands, queries, events, handlers, consumers, tasks, sagas, pipelines, projections, workflows, webhooks, websocket, clients, notifications, policies, admin/actions, admin/resources
App-level providers live in src/<app>/di/ (*_provider.py). Cross-cutting provider packages still land in shared/providers/.
Project root, never moved: migrations/versions, seeds.
tests/unit follows the node: with --module auth a generated test lands in src/<app>/modules/auth/tests/.
The full generator → path map lives with the CLI: Project layout. If that dump still shows models/ or oridecon gen model, this site wins: generated feature types land in domains/, app providers in di/.
Composition root
Section titled “Composition root”One create_app() serves a flat project and one full of bounded contexts. List the modules this app uses. Controllers are discovered from both the app-root package and modules/ — never listed by hand.
from oridecon import Application, OrideconConfigfrom oridecon.web import WebModule
from my_app.modules.auth import AuthModule
def create_app(config: OrideconConfig | None = None) -> Application: application = Application(name="my-app", config=config) application.add_modules( [ AuthModule, WebModule.configure( discover=[ "my_app.controllers", "my_app.modules", ] ), ] ) return application
app = create_app()An unscoped controller lives at the app root; a scoped one lives inside its module. Listing them in the composition root would let it wire a controller the module should own.
When you add SQL or an agent, pass DatabaseModule.configure(...) or AgentsModule.configure(...) in the same list, then application.add_providers([...]) for app-root providers — that is how examples/sql-repository and examples/support-agent boot.
A module boundary
Section titled “A module boundary”oridecon new module auth writes the @module class, a protocol file, and a provider. Other modules import the protocol, never the implementation.
from oridecon.di.module import Module, module
from my_app.modules.auth.provider import AuthProviderfrom my_app.modules.auth.protocols import AuthServiceProtocol
@module( providers=[AuthProvider], exports=[AuthServiceProtocol],)class AuthModule(Module): """Authentication — only AuthServiceProtocol is visible to importers."""| Convention | Why |
|---|---|
__init__.py is the module boundary | The @module class is the public API of the package |
protocols.py is the contract | Other modules import protocols, never concrete classes |
exports=[…] controls visibility | Only exported types are accessible to importers |
| The provider stays internal | It registers services; it is not imported by other modules |
You can still mix a standalone provider with modules in the same app:
app.add_module(AuthModule) # bounded — exports onlyapp.add_provider(MetricsProvider()) # standalone — globally visibleKey files
Section titled “Key files”| File | Purpose |
|---|---|
src/<app>/app.py | Composition root. oridecon run / oridecon dev boot <app>.app:app |
application.yaml | Typed config, loaded by OrideconConfig |
src/<app>/infrastructure/ | Framework wiring — db, cache, auth, tasks |
src/<app>/controllers/ | Unscoped HTTP controllers, auto-discovered |
src/<app>/domains/ | Top-level domain types (module-local copy lives under modules/<slug>/domains/) |
src/<app>/di/ | App providers (*_provider.py) |
src/<app>/shared/ | Cross-cutting components (oridecon gen error, middleware, …) |
src/<app>/modules/ | Bounded contexts (oridecon new module) |
tests/conftest.py | Boots create_app() for pytest |
Next Steps
Section titled “Next Steps”- Your First App — a three-file API on this same tree
- For coding agents — repo map and recipes that pass CI
- Core Concepts — Providers, DI, Result, and why modules encapsulate
- The oridecon CLI —
new,gen,run,dev - Project layout (CLI) — the canonical generator map