Skip to content
Packages Examples Agents Blog Get started

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.


  1. 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.
  2. Is this node in a module? A module-local component lands in src/<app>/<component>/ while the node is unscoped, and in src/<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.


Terminal window
oridecon new project my-app --template web-api

Templates (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.py

A fresh project ships no sample module. Feature directories such as services/, domains/, and di/ appear when you generate them.

Terminal window
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)

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.

Terminal window
oridecon new module auth
# → src/my_app/modules/auth/{__init__.py, protocols.py, provider.py, services.py}
# → list AuthModule in create_app() next to WebModule

Then generate into it:

Terminal window
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 auth

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/.


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.

src/my_app/app.py
from oridecon import Application, OrideconConfig
from 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.


oridecon new module auth writes the @module class, a protocol file, and a provider. Other modules import the protocol, never the implementation.

src/my_app/modules/auth/__init__.py
from oridecon.di.module import Module, module
from my_app.modules.auth.provider import AuthProvider
from my_app.modules.auth.protocols import AuthServiceProtocol
@module(
providers=[AuthProvider],
exports=[AuthServiceProtocol],
)
class AuthModule(Module):
"""Authentication — only AuthServiceProtocol is visible to importers."""
ConventionWhy
__init__.py is the module boundaryThe @module class is the public API of the package
protocols.py is the contractOther modules import protocols, never concrete classes
exports=[…] controls visibilityOnly exported types are accessible to importers
The provider stays internalIt 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 only
app.add_provider(MetricsProvider()) # standalone — globally visible

FilePurpose
src/<app>/app.pyComposition root. oridecon run / oridecon dev boot <app>.app:app
application.yamlTyped 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.pyBoots create_app() for pytest