Skip to content
Packages Examples Agents Blog Get started

One project layout. Every project has the same tree, built from the same 48 oridecon gen generators against one canonical generator→path map (oridecon/cli/layout.py), so scaffolding and code generation stay in lockstep.

There is no --structure flag and no [tool.oridecon] structure key. What used to be three project shapes is one shape plus a per-node fact: whether that node has joined a module.

Implementation status (all shipped in this CLI):

  • oridecon new project --template <t> — 6 templates, one layout
  • oridecon new module <name> — bounded-context creation + registry
  • oridecon gen <generator> <name> [--module <feature>] — path resolution
  • Canonical map enforced by the dev gate (dev/checks/generator_output.py) and the unit suite (tests/unit/test_layout.py)
  • SDK renames src/graphql → src/schema (+ schema/dataloaders) and src/collections → src/vector/collections

Two questions decide every path.

  1. Is this component cross-cutting? Cross-cutting components are one per application by definition (errors, middleware, providers, health…). They land in src/<app>/shared/<component>/ and stay there whatever module the node belongs to.
  2. Is this node in a module? A module-local component lands in src/<app>/<component>/ while the node is unscoped, and moves to src/<app>/modules/<slug>/<component>/ the moment it joins a module.

The composition root is always src/<app>/app.py, and the ASGI target is always <app>.app:app.

Unscoped feature code sits at the app package root, not in shared/, so shared/ keeps exactly one meaning: cross-cutting. A project that never draws a module simply never has a modules/<slug>/ directory.


my-platform/
├── application.yaml
├── application.production.yaml # optional profile overlays
├── pyproject.toml # [tool.oridecon] module = "my_platform.app:app"
├── README.md
├── .env.example
├── migrations/versions/ # oridecon gen migration
├── seeds/ # oridecon gen seeder
├── src/
│ └── my_platform/
│ ├── __init__.py
│ ├── app.py # create_app() — the composition root
│ ├── py.typed
│ ├── controllers/ # unscoped feature code: app-root components
│ ├── models/ # appear as they are generated
│ ├── di/
│ ├── services/
│ ├── infrastructure/ # framework wiring: db, cache, events, monitoring
│ │ └── __init__.py # infrastructure_modules()
│ ├── shared/ # cross-cutting components (see §3)
│ │ ├── errors/
│ │ ├── filters/
│ │ ├── middleware/
│ │ ├── interceptors/
│ │ ├── metrics/
│ │ ├── health/
│ │ ├── audit/
│ │ ├── tenancy/
│ │ ├── features/
│ │ ├── search/
│ │ ├── storage/backends/
│ │ ├── providers/
│ │ ├── schema/dataloaders/
│ │ ├── vector/collections/
│ │ └── mcp/
│ └── modules/
│ ├── __init__.py # MODULES registry
│ ├── auth/
│ │ ├── __init__.py # @module AuthModule(controllers=[…], exports=[…])
│ │ ├── protocols.py # the module contract
│ │ ├── provider.py # AuthProvider (register/boot/shutdown)
│ │ ├── services.py
│ │ ├── controllers/ # the same components, now module-local
│ │ ├── models/
│ │ ├── repositories/
│ │ └── tests/ # oridecon gen test --module auth
│ └── billing/
│ └── …same shape…
└── tests/
├── conftest.py # boots create_app()
├── test_app.py
└── unit/ # oridecon gen test (unscoped)

A fresh project ships no sample module: modules/__init__.py exports an empty MODULES, and oridecon new module <name> fills it in.


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>/):

admin/actions, admin/resources, clients, commands, consumers, controllers, events, handlers, models, notifications, pipelines, policies, projections, queries, repositories, sagas, services, tasks, webhooks, websocket, workflows

Project root, never moved: migrations/versions, seeds. tests/unit is the exception that follows its node: with --module auth a generated test lands in src/<app>/modules/auth/tests/.


Paths below are the declared defaults in the generator definitions; the resolver rewrites them per §1. src/controllers therefore means src/<app>/controllers unscoped and src/<app>/modules/<m>/controllers scoped, while src/errors always means src/<app>/shared/errors.

Generator(s)Declared directory
controllersrc/controllers
modelsrc/models
servicesrc/services
repository, cache_repo, document_reposrc/repositories
providersrc/providers
querysrc/queries
errorsrc/errors
event / event_handlersrc/events / src/handlers
commandsrc/commands
consumer (message_consumer)src/consumers
tasksrc/tasks
saga, saga_stepsrc/sagas
pipelinesrc/pipelines
projectionsrc/projections
workflow_defsrc/workflows
middlewaresrc/middleware
interceptorsrc/interceptors
filter, exception_filtersrc/filters
guard, auth_guardsrc/guards
policy (auth_policy)src/policies
healthsrc/health
metricsrc/metrics
webhooksrc/webhooks
websocketsrc/websocket
api_clientsrc/clients
notification_templatesrc/notifications
feature_flagsrc/features
tenant_resolversrc/tenancy
search_indexsrc/search
storage_driversrc/storage/backends
mcp-controller, mcp-serversrc/mcp
admin_action / admin_resourcesrc/admin/actions / src/admin/resources
auditedsrc/audit
graphql / dataloadersrc/schema / src/schema/dataloaders
vector_collectionsrc/vector/collections
(special) resourcesrc — writes src/<app>/<name>_resource.py, or into the module
migration / seedermigrations/versions / seeds
testtests/unit

Two default dirs were renamed because they shadowed real modules on sys.path: src/graphql → src/schema (shadowed graphql, used by strawberry) and src/collections → src/vector/collections (shadowed the stdlib collections). Because every component now lives under the app package, importing src/<app>/ can never shadow stdlib or site-packages — the layout is import-safe by construction.


def create_app(config: OrideconConfig | None = None) -> Application:
application = Application(name="my-platform", config=config)
application.add_modules(
[
*infrastructure_modules(), # db, cache, events, monitoring
*MODULES, # the bounded contexts
WebModule.configure(
discover=[
"my_platform.controllers",
"my_platform.modules",
]
),
]
)
return application
app = create_app()

Controllers are discovered, never registered by hand — in both roots, because an unscoped controller lives at the app root and a scoped one lives inside its module. Listing them explicitly would let the composition root wire a controller the module should own.


Terminal window
oridecon new project my-app --template web-api
oridecon new module auth # adds src/my_app/modules/auth/
oridecon gen controller users # src/my_app/controllers/…
oridecon gen controller users --module auth # src/my_app/modules/auth/controllers/…
oridecon gen error not_found # src/my_app/shared/errors/… (module ignored)

--module is a per-invocation fact, never project state. Nothing has to be migrated, converted or “switched”: a project grows a bounded context by adding one, and the components that move are exactly the ones scoped into it.

  1. One canonical map. oridecon/cli/layout.py holds a single component → (directory, cross-cutting?) table used by oridecon gen (path resolution), oridecon new project (scaffold dirs) and the alignment gate (dev/checks/generator_output.py).
  2. Same files everywhere. application.yaml, pyproject.toml, README.md, .env.example, tests/conftest.py and tests/test_app.py come from the one render_project().
  3. Runtime parity. [tool.oridecon] module always names <app>.app:app, so oridecon dev / oridecon run boot the object the composition root exposes rather than a re-export of it.
  4. Import safety. No generated package name may collide with stdlib or installed site-packages — enforced by the alignment gate.